pocketbase
Version:
PocketBase JavaScript SDK
1 lines • 198 kB
Source Map (JSON)
{"version":3,"file":"pocketbase.es.mjs","sources":["../src/ClientResponseError.ts","../src/tools/cookie.ts","../src/tools/jwt.ts","../src/stores/BaseAuthStore.ts","../src/stores/LocalAuthStore.ts","../src/services/BaseService.ts","../src/services/SettingsService.ts","../src/tools/options.ts","../src/services/RealtimeService.ts","../src/services/CrudService.ts","../src/tools/legacy.ts","../src/tools/refresh.ts","../src/services/RecordService.ts","../src/services/CollectionService.ts","../src/services/LogService.ts","../src/services/HealthService.ts","../src/services/FileService.ts","../src/services/BackupService.ts","../src/services/CronService.ts","../src/tools/formdata.ts","../src/services/BatchService.ts","../src/Client.ts","../src/stores/AsyncAuthStore.ts"],"sourcesContent":["/**\n * ClientResponseError is a custom Error class that is intended to wrap\n * and normalize any error thrown by `Client.send()`.\n */\nexport class ClientResponseError extends Error {\n url: string = \"\";\n status: number = 0;\n response: { [key: string]: any } = {};\n isAbort: boolean = false;\n originalError: any = null;\n\n constructor(errData?: any) {\n super(\"ClientResponseError\");\n\n // Set the prototype explicitly.\n // https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work\n Object.setPrototypeOf(this, ClientResponseError.prototype);\n\n if (errData !== null && typeof errData === \"object\") {\n this.url = typeof errData.url === \"string\" ? errData.url : \"\";\n this.status = typeof errData.status === \"number\" ? errData.status : 0;\n this.isAbort = !!errData.isAbort;\n this.originalError = errData.originalError;\n\n if (errData.response !== null && typeof errData.response === \"object\") {\n this.response = errData.response;\n } else if (errData.data !== null && typeof errData.data === \"object\") {\n this.response = errData.data;\n } else {\n this.response = {};\n }\n }\n\n if (!this.originalError && !(errData instanceof ClientResponseError)) {\n this.originalError = errData;\n }\n\n if (typeof DOMException !== \"undefined\" && errData instanceof DOMException) {\n this.isAbort = true;\n }\n\n this.name = \"ClientResponseError \" + this.status;\n this.message = this.response?.message;\n if (!this.message) {\n if (this.isAbort) {\n this.message =\n \"The request was autocancelled. You can find more info in https://github.com/pocketbase/js-sdk#auto-cancellation.\";\n } else if (this.originalError?.cause?.message?.includes(\"ECONNREFUSED ::1\")) {\n this.message =\n \"Failed to connect to the PocketBase server. Try changing the SDK URL from localhost to 127.0.0.1 (https://github.com/pocketbase/js-sdk/issues/21).\";\n } else {\n this.message = \"Something went wrong while processing your request.\";\n }\n }\n }\n\n /**\n * Alias for `this.response` for backward compatibility.\n */\n get data() {\n return this.response;\n }\n\n /**\n * Make a POJO's copy of the current error class instance.\n * @see https://github.com/vuex-orm/vuex-orm/issues/255\n */\n toJSON() {\n return { ...this };\n }\n}\n","/**\n * -------------------------------------------------------------------\n * Simple cookie parse and serialize utilities mostly based on the\n * node module https://github.com/jshttp/cookie.\n * -------------------------------------------------------------------\n */\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar = VCHAR / obs-text\n * obs-text = %x80-FF\n */\nconst fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\nexport interface ParseOptions {\n decode?: (val: string) => string;\n}\n\n/**\n * Parses the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n */\nexport function cookieParse(str: string, options?: ParseOptions): { [key: string]: any } {\n const result: { [key: string]: any } = {};\n\n if (typeof str !== \"string\") {\n return result;\n }\n\n const opt = Object.assign({}, options || {});\n const decode = opt.decode || defaultDecode;\n\n let index = 0;\n while (index < str.length) {\n const eqIdx = str.indexOf(\"=\", index);\n\n // no more cookie pairs\n if (eqIdx === -1) {\n break;\n }\n\n let endIdx = str.indexOf(\";\", index);\n\n if (endIdx === -1) {\n endIdx = str.length;\n } else if (endIdx < eqIdx) {\n // backtrack on prior semicolon\n index = str.lastIndexOf(\";\", eqIdx - 1) + 1;\n continue;\n }\n\n const key = str.slice(index, eqIdx).trim();\n\n // only assign once\n if (undefined === result[key]) {\n let val = str.slice(eqIdx + 1, endIdx).trim();\n\n // quoted values\n if (val.charCodeAt(0) === 0x22) {\n val = val.slice(1, -1);\n }\n\n try {\n result[key] = decode(val);\n } catch (_) {\n result[key] = val; // no decoding\n }\n }\n\n index = endIdx + 1;\n }\n\n return result;\n}\n\nexport interface SerializeOptions {\n encode?: (val: string | number | boolean) => string;\n maxAge?: number;\n domain?: string;\n path?: string;\n expires?: Date;\n httpOnly?: boolean;\n secure?: boolean;\n priority?: string;\n sameSite?: boolean | string;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * ```js\n * cookieSerialize('foo', 'bar', { httpOnly: true }) // \"foo=bar; httpOnly\"\n * ```\n */\nexport function cookieSerialize(\n name: string,\n val: string,\n options?: SerializeOptions,\n): string {\n const opt = Object.assign({}, options || {});\n const encode = opt.encode || defaultEncode;\n\n if (!fieldContentRegExp.test(name)) {\n throw new TypeError(\"argument name is invalid\");\n }\n\n const value = encode(val);\n\n if (value && !fieldContentRegExp.test(value)) {\n throw new TypeError(\"argument val is invalid\");\n }\n\n let result = name + \"=\" + value;\n\n if (opt.maxAge != null) {\n const maxAge = opt.maxAge - 0;\n\n if (isNaN(maxAge) || !isFinite(maxAge)) {\n throw new TypeError(\"option maxAge is invalid\");\n }\n\n result += \"; Max-Age=\" + Math.floor(maxAge);\n }\n\n if (opt.domain) {\n if (!fieldContentRegExp.test(opt.domain)) {\n throw new TypeError(\"option domain is invalid\");\n }\n\n result += \"; Domain=\" + opt.domain;\n }\n\n if (opt.path) {\n if (!fieldContentRegExp.test(opt.path)) {\n throw new TypeError(\"option path is invalid\");\n }\n\n result += \"; Path=\" + opt.path;\n }\n\n if (opt.expires) {\n if (!isDate(opt.expires) || isNaN(opt.expires.valueOf())) {\n throw new TypeError(\"option expires is invalid\");\n }\n\n result += \"; Expires=\" + opt.expires.toUTCString();\n }\n\n if (opt.httpOnly) {\n result += \"; HttpOnly\";\n }\n\n if (opt.secure) {\n result += \"; Secure\";\n }\n\n if (opt.priority) {\n const priority =\n typeof opt.priority === \"string\" ? opt.priority.toLowerCase() : opt.priority;\n\n switch (priority) {\n case \"low\":\n result += \"; Priority=Low\";\n break;\n case \"medium\":\n result += \"; Priority=Medium\";\n break;\n case \"high\":\n result += \"; Priority=High\";\n break;\n default:\n throw new TypeError(\"option priority is invalid\");\n }\n }\n\n if (opt.sameSite) {\n const sameSite =\n typeof opt.sameSite === \"string\" ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n switch (sameSite) {\n case true:\n result += \"; SameSite=Strict\";\n break;\n case \"lax\":\n result += \"; SameSite=Lax\";\n break;\n case \"strict\":\n result += \"; SameSite=Strict\";\n break;\n case \"none\":\n result += \"; SameSite=None\";\n break;\n default:\n throw new TypeError(\"option sameSite is invalid\");\n }\n }\n\n return result;\n}\n\n/**\n * Default URL-decode string value function.\n * Optimized to skip native call when no `%`.\n */\nfunction defaultDecode(val: string): string {\n return val.indexOf(\"%\") !== -1 ? decodeURIComponent(val) : val;\n}\n\n/**\n * Default URL-encode value function.\n */\nfunction defaultEncode(val: string | number | boolean): string {\n return encodeURIComponent(val);\n}\n\n/**\n * Determines if value is a Date.\n */\nfunction isDate(val: any): boolean {\n return Object.prototype.toString.call(val) === \"[object Date]\" || val instanceof Date;\n}\n","// @todo remove after https://github.com/reactwg/react-native-releases/issues/287\nconst isReactNative =\n (typeof navigator !== \"undefined\" && navigator.product === \"ReactNative\") ||\n (typeof global !== \"undefined\" && (global as any).HermesInternal);\n\nlet atobPolyfill: Function;\nif (typeof atob === \"function\" && !isReactNative) {\n atobPolyfill = atob;\n} else {\n /**\n * The code was extracted from:\n * https://github.com/davidchambers/Base64.js\n */\n atobPolyfill = (input: any) => {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\";\n\n let str = String(input).replace(/=+$/, \"\");\n if (str.length % 4 == 1) {\n throw new Error(\n \"'atob' failed: The string to be decoded is not correctly encoded.\",\n );\n }\n\n for (\n // initialize result and counters\n var bc = 0, bs, buffer, idx = 0, output = \"\";\n // get next character\n (buffer = str.charAt(idx++));\n // character found in table? initialize bit storage and add its ascii value;\n ~buffer &&\n ((bs = bc % 4 ? (bs as any) * 64 + buffer : buffer),\n // and if not first of each 4 characters,\n // convert the first 8 bits to one ascii character\n bc++ % 4)\n ? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))))\n : 0\n ) {\n // try to find character in table (0-63, not found => -1)\n buffer = chars.indexOf(buffer);\n }\n\n return output;\n };\n}\n\n/**\n * Returns JWT token's payload data.\n */\nexport function getTokenPayload(token: string): { [key: string]: any } {\n if (token) {\n try {\n const encodedPayload = decodeURIComponent(\n atobPolyfill(token.split(\".\")[1])\n .split(\"\")\n .map(function (c: string) {\n return \"%\" + (\"00\" + c.charCodeAt(0).toString(16)).slice(-2);\n })\n .join(\"\"),\n );\n\n return JSON.parse(encodedPayload) || {};\n } catch (e) {}\n }\n\n return {};\n}\n\n/**\n * Checks whether a JWT token is expired or not.\n * Tokens without `exp` payload key are considered valid.\n * Tokens with empty payload (eg. invalid token strings) are considered expired.\n *\n * @param token The token to check.\n * @param [expirationThreshold] Time in seconds that will be subtracted from the token `exp` property.\n */\nexport function isTokenExpired(token: string, expirationThreshold = 0): boolean {\n let payload = getTokenPayload(token);\n\n if (\n Object.keys(payload).length > 0 &&\n (!payload.exp || payload.exp - expirationThreshold > Date.now() / 1000)\n ) {\n return false;\n }\n\n return true;\n}\n","import { cookieParse, cookieSerialize, SerializeOptions } from \"@/tools/cookie\";\nimport { isTokenExpired, getTokenPayload } from \"@/tools/jwt\";\nimport { RecordModel } from \"@/tools/dtos\";\n\nexport type AuthRecord = RecordModel | null;\n\nexport type AuthModel = AuthRecord; // for backward compatibility\n\nexport type OnStoreChangeFunc = (token: string, record: AuthRecord) => void;\n\nconst defaultCookieKey = \"pb_auth\";\n\n/**\n * Base AuthStore class that stores the auth state in runtime memory (aka. only for the duration of the store instane).\n *\n * Usually you wouldn't use it directly and instead use the builtin LocalAuthStore, AsyncAuthStore\n * or extend it with your own custom implementation.\n */\nexport class BaseAuthStore {\n protected baseToken: string = \"\";\n protected baseModel: AuthRecord = null;\n\n private _onChangeCallbacks: Array<OnStoreChangeFunc> = [];\n\n /**\n * Retrieves the stored token (if any).\n */\n get token(): string {\n return this.baseToken;\n }\n\n /**\n * Retrieves the stored model data (if any).\n */\n get record(): AuthRecord {\n return this.baseModel;\n }\n\n /**\n * @deprecated use `record` instead.\n */\n get model(): AuthRecord {\n return this.baseModel;\n }\n\n /**\n * Loosely checks if the store has valid token (aka. existing and unexpired exp claim).\n */\n get isValid(): boolean {\n return !isTokenExpired(this.token);\n }\n\n /**\n * Loosely checks whether the currently loaded store state is for superuser.\n *\n * Alternatively you can also compare directly `pb.authStore.record?.collectionName`.\n */\n get isSuperuser(): boolean {\n let payload = getTokenPayload(this.token);\n\n return (\n payload.type == \"auth\" &&\n (this.record?.collectionName == \"_superusers\" ||\n // fallback in case the record field is not populated and assuming\n // that the collection crc32 checksum id wasn't manually changed\n (!this.record?.collectionName &&\n payload.collectionId == \"pbc_3142635823\"))\n );\n }\n\n /**\n * @deprecated use `isSuperuser` instead or simply check the record.collectionName property.\n */\n get isAdmin(): boolean {\n console.warn(\n \"Please replace pb.authStore.isAdmin with pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName\",\n );\n return this.isSuperuser;\n }\n\n /**\n * @deprecated use `!isSuperuser` instead or simply check the record.collectionName property.\n */\n get isAuthRecord(): boolean {\n console.warn(\n \"Please replace pb.authStore.isAuthRecord with !pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName\",\n );\n return getTokenPayload(this.token).type == \"auth\" && !this.isSuperuser;\n }\n\n /**\n * Saves the provided new token and model data in the auth store.\n */\n save(token: string, record?: AuthRecord): void {\n this.baseToken = token || \"\";\n this.baseModel = record || null;\n\n this.triggerChange();\n }\n\n /**\n * Removes the stored token and model data form the auth store.\n */\n clear(): void {\n this.baseToken = \"\";\n this.baseModel = null;\n this.triggerChange();\n }\n\n /**\n * Parses the provided cookie string and updates the store state\n * with the cookie's token and model data.\n *\n * NB! This function doesn't validate the token or its data.\n * Usually this isn't a concern if you are interacting only with the\n * PocketBase API because it has the proper server-side security checks in place,\n * but if you are using the store `isValid` state for permission controls\n * in a node server (eg. SSR), then it is recommended to call `authRefresh()`\n * after loading the cookie to ensure an up-to-date token and model state.\n * For example:\n *\n * ```js\n * pb.authStore.loadFromCookie(\"cookie string...\");\n *\n * try {\n * // get an up-to-date auth store state by veryfing and refreshing the loaded auth model (if any)\n * pb.authStore.isValid && await pb.collection('users').authRefresh();\n * } catch (_) {\n * // clear the auth store on failed refresh\n * pb.authStore.clear();\n * }\n * ```\n */\n loadFromCookie(cookie: string, key = defaultCookieKey): void {\n const rawData = cookieParse(cookie || \"\")[key] || \"\";\n\n let data: { [key: string]: any } = {};\n try {\n data = JSON.parse(rawData);\n // normalize\n if (typeof data === null || typeof data !== \"object\" || Array.isArray(data)) {\n data = {};\n }\n } catch (_) {}\n\n this.save(data.token || \"\", data.record || data.model || null);\n }\n\n /**\n * Exports the current store state as cookie string.\n *\n * By default the following optional attributes are added:\n * - Secure\n * - HttpOnly\n * - SameSite=Strict\n * - Path=/\n * - Expires={the token expiration date}\n *\n * NB! If the generated cookie exceeds 4096 bytes, this method will\n * strip the model data to the bare minimum to try to fit within the\n * recommended size in https://www.rfc-editor.org/rfc/rfc6265#section-6.1.\n */\n exportToCookie(options?: SerializeOptions, key = defaultCookieKey): string {\n const defaultOptions: SerializeOptions = {\n secure: true,\n sameSite: true,\n httpOnly: true,\n path: \"/\",\n };\n\n // extract the token expiration date\n const payload = getTokenPayload(this.token);\n if (payload?.exp) {\n defaultOptions.expires = new Date(payload.exp * 1000);\n } else {\n defaultOptions.expires = new Date(\"1970-01-01\");\n }\n\n // merge with the user defined options\n options = Object.assign({}, defaultOptions, options);\n\n const rawData = {\n token: this.token,\n record: this.record ? JSON.parse(JSON.stringify(this.record)) : null,\n };\n\n let result = cookieSerialize(key, JSON.stringify(rawData), options);\n\n const resultLength =\n typeof Blob !== \"undefined\" ? new Blob([result]).size : result.length;\n\n // strip down the model data to the bare minimum\n if (rawData.record && resultLength > 4096) {\n rawData.record = { id: rawData.record?.id, email: rawData.record?.email };\n const extraProps = [\"collectionId\", \"collectionName\", \"verified\"];\n for (const prop in this.record) {\n if (extraProps.includes(prop)) {\n rawData.record[prop] = this.record[prop];\n }\n }\n result = cookieSerialize(key, JSON.stringify(rawData), options);\n }\n\n return result;\n }\n\n /**\n * Register a callback function that will be called on store change.\n *\n * You can set the `fireImmediately` argument to true in order to invoke\n * the provided callback right after registration.\n *\n * Returns a removal function that you could call to \"unsubscribe\" from the changes.\n */\n onChange(callback: OnStoreChangeFunc, fireImmediately = false): () => void {\n this._onChangeCallbacks.push(callback);\n\n if (fireImmediately) {\n callback(this.token, this.record);\n }\n\n return () => {\n for (let i = this._onChangeCallbacks.length - 1; i >= 0; i--) {\n if (this._onChangeCallbacks[i] == callback) {\n delete this._onChangeCallbacks[i]; // removes the function reference\n this._onChangeCallbacks.splice(i, 1); // reindex the array\n return;\n }\n }\n };\n }\n\n protected triggerChange(): void {\n for (const callback of this._onChangeCallbacks) {\n callback && callback(this.token, this.record);\n }\n }\n}\n","import { BaseAuthStore, AuthRecord } from \"@/stores/BaseAuthStore\";\n\n/**\n * The default token store for browsers with auto fallback\n * to runtime/memory if local storage is undefined (e.g. in node env).\n */\nexport class LocalAuthStore extends BaseAuthStore {\n private storageFallback: { [key: string]: any } = {};\n private storageKey: string;\n\n constructor(storageKey = \"pocketbase_auth\") {\n super();\n\n this.storageKey = storageKey;\n\n this._bindStorageEvent();\n }\n\n /**\n * @inheritdoc\n */\n get token(): string {\n const data = this._storageGet(this.storageKey) || {};\n\n return data.token || \"\";\n }\n\n /**\n * @inheritdoc\n */\n get record(): AuthRecord {\n const data = this._storageGet(this.storageKey) || {};\n\n return data.record || data.model || null;\n }\n\n /**\n * @deprecated use `record` instead.\n */\n get model(): AuthRecord {\n return this.record;\n }\n\n /**\n * @inheritdoc\n */\n save(token: string, record?: AuthRecord) {\n this._storageSet(this.storageKey, {\n token: token,\n record: record,\n });\n\n super.save(token, record);\n }\n\n /**\n * @inheritdoc\n */\n clear() {\n this._storageRemove(this.storageKey);\n\n super.clear();\n }\n\n // ---------------------------------------------------------------\n // Internal helpers:\n // ---------------------------------------------------------------\n\n /**\n * Retrieves `key` from the browser's local storage\n * (or runtime/memory if local storage is undefined).\n */\n private _storageGet(key: string): any {\n if (typeof window !== \"undefined\" && window?.localStorage) {\n const rawValue = window.localStorage.getItem(key) || \"\";\n try {\n return JSON.parse(rawValue);\n } catch (e) {\n // not a json\n return rawValue;\n }\n }\n\n // fallback\n return this.storageFallback[key];\n }\n\n /**\n * Stores a new data in the browser's local storage\n * (or runtime/memory if local storage is undefined).\n */\n private _storageSet(key: string, value: any) {\n if (typeof window !== \"undefined\" && window?.localStorage) {\n // store in local storage\n let normalizedVal = value;\n if (typeof value !== \"string\") {\n normalizedVal = JSON.stringify(value);\n }\n window.localStorage.setItem(key, normalizedVal);\n } else {\n // store in fallback\n this.storageFallback[key] = value;\n }\n }\n\n /**\n * Removes `key` from the browser's local storage and the runtime/memory.\n */\n private _storageRemove(key: string) {\n // delete from local storage\n if (typeof window !== \"undefined\" && window?.localStorage) {\n window.localStorage?.removeItem(key);\n }\n\n // delete from fallback\n delete this.storageFallback[key];\n }\n\n /**\n * Updates the current store state on localStorage change.\n */\n private _bindStorageEvent() {\n if (\n typeof window === \"undefined\" ||\n !window?.localStorage ||\n !window.addEventListener\n ) {\n return;\n }\n\n window.addEventListener(\"storage\", (e) => {\n if (e.key != this.storageKey) {\n return;\n }\n\n const data = this._storageGet(this.storageKey) || {};\n\n super.save(data.token || \"\", data.record || data.model || null);\n });\n }\n}\n","import Client from \"@/Client\";\n\n/**\n * BaseService class that should be inherited from all API services.\n */\nexport abstract class BaseService {\n readonly client: Client;\n\n constructor(client: Client) {\n this.client = client;\n }\n}\n","import { BaseService } from \"@/services/BaseService\";\nimport { CommonOptions } from \"@/tools/options\";\n\ninterface appleClientSecret {\n secret: string;\n}\n\nexport class SettingsService extends BaseService {\n /**\n * Fetch all available app settings.\n *\n * @throws {ClientResponseError}\n */\n async getAll(options?: CommonOptions): Promise<{ [key: string]: any }> {\n options = Object.assign(\n {\n method: \"GET\",\n },\n options,\n );\n\n return this.client.send(\"/api/settings\", options);\n }\n\n /**\n * Bulk updates app settings.\n *\n * @throws {ClientResponseError}\n */\n async update(\n bodyParams?: { [key: string]: any } | FormData,\n options?: CommonOptions,\n ): Promise<{ [key: string]: any }> {\n options = Object.assign(\n {\n method: \"PATCH\",\n body: bodyParams,\n },\n options,\n );\n\n return this.client.send(\"/api/settings\", options);\n }\n\n /**\n * Performs a S3 filesystem connection test.\n *\n * The currently supported `filesystem` are \"storage\" and \"backups\".\n *\n * @throws {ClientResponseError}\n */\n async testS3(\n filesystem: string = \"storage\",\n options?: CommonOptions,\n ): Promise<boolean> {\n options = Object.assign(\n {\n method: \"POST\",\n body: {\n filesystem: filesystem,\n },\n },\n options,\n );\n\n return this.client.send(\"/api/settings/test/s3\", options).then(() => true);\n }\n\n /**\n * Sends a test email.\n *\n * The possible `emailTemplate` values are:\n * - verification\n * - password-reset\n * - email-change\n *\n * @throws {ClientResponseError}\n */\n async testEmail(\n collectionIdOrName: string,\n toEmail: string,\n emailTemplate: string,\n options?: CommonOptions,\n ): Promise<boolean> {\n options = Object.assign(\n {\n method: \"POST\",\n body: {\n email: toEmail,\n template: emailTemplate,\n collection: collectionIdOrName,\n },\n },\n options,\n );\n\n return this.client.send(\"/api/settings/test/email\", options).then(() => true);\n }\n\n /**\n * Generates a new Apple OAuth2 client secret.\n *\n * @throws {ClientResponseError}\n */\n async generateAppleClientSecret(\n clientId: string,\n teamId: string,\n keyId: string,\n privateKey: string,\n duration: number,\n options?: CommonOptions,\n ): Promise<appleClientSecret> {\n options = Object.assign(\n {\n method: \"POST\",\n body: {\n clientId,\n teamId,\n keyId,\n privateKey,\n duration,\n },\n },\n options,\n );\n\n return this.client.send(\"/api/settings/apple/generate-client-secret\", options);\n }\n}\n","export interface SendOptions extends RequestInit {\n // for backward compatibility and to minimize the verbosity,\n // any top-level field that doesn't exist in RequestInit or the\n // fields below will be treated as query parameter.\n [key: string]: any;\n\n /**\n * Optional custom fetch function to use for sending the request.\n */\n fetch?: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>;\n\n /**\n * Custom headers to send with the requests.\n */\n headers?: { [key: string]: string };\n\n /**\n * The body of the request (serialized automatically for json requests).\n */\n body?: any;\n\n /**\n * Query parameters that will be appended to the request url.\n */\n query?: { [key: string]: any };\n\n /**\n * @deprecated use `query` instead\n *\n * for backward-compatibility `params` values are merged with `query`,\n * but this option may get removed in the final v1 release\n */\n params?: { [key: string]: any };\n\n /**\n * The request identifier that can be used to cancel pending requests.\n */\n requestKey?: string | null;\n\n /**\n * @deprecated use `requestKey:string` instead\n */\n $cancelKey?: string;\n\n /**\n * @deprecated use `requestKey:null` instead\n */\n $autoCancel?: boolean;\n}\n\nexport interface CommonOptions extends SendOptions {\n fields?: string;\n}\n\nexport interface ListOptions extends CommonOptions {\n page?: number;\n perPage?: number;\n sort?: string;\n filter?: string;\n skipTotal?: boolean;\n}\n\nexport interface FullListOptions extends ListOptions {\n batch?: number;\n}\n\nexport interface RecordOptions extends CommonOptions {\n expand?: string;\n}\n\nexport interface RecordListOptions extends ListOptions, RecordOptions {}\n\nexport interface RecordFullListOptions extends FullListOptions, RecordOptions {}\n\nexport interface RecordSubscribeOptions extends SendOptions {\n fields?: string;\n filter?: string;\n expand?: string;\n}\n\nexport interface LogStatsOptions extends CommonOptions {\n filter?: string;\n}\n\nexport interface FileOptions extends CommonOptions {\n thumb?: string;\n download?: boolean;\n}\n\nexport interface AuthOptions extends CommonOptions {\n /**\n * If autoRefreshThreshold is set it will take care to auto refresh\n * when necessary the auth data before each request to ensure that\n * the auth state is always valid.\n *\n * The value must be in seconds, aka. the amount of seconds\n * that will be subtracted from the current token `exp` claim in order\n * to determine whether it is going to expire within the specified time threshold.\n *\n * For example, if you want to auto refresh the token if it is\n * going to expire in the next 30mins (or already has expired),\n * it can be set to `1800`\n */\n autoRefreshThreshold?: number;\n}\n\n// -------------------------------------------------------------------\n\n// list of known SendOptions keys (everything else is treated as query param)\nconst knownSendOptionsKeys = [\n \"requestKey\",\n \"$cancelKey\",\n \"$autoCancel\",\n \"fetch\",\n \"headers\",\n \"body\",\n \"query\",\n \"params\",\n // ---,\n \"cache\",\n \"credentials\",\n \"headers\",\n \"integrity\",\n \"keepalive\",\n \"method\",\n \"mode\",\n \"redirect\",\n \"referrer\",\n \"referrerPolicy\",\n \"signal\",\n \"window\",\n];\n\n// modifies in place the provided options by moving unknown send options as query parameters.\nexport function normalizeUnknownQueryParams(options?: SendOptions): void {\n if (!options) {\n return;\n }\n\n options.query = options.query || {};\n for (let key in options) {\n if (knownSendOptionsKeys.includes(key)) {\n continue;\n }\n\n options.query[key] = options[key];\n delete options[key];\n }\n}\n\nexport function serializeQueryParams(params: { [key: string]: any }): string {\n const result: Array<string> = [];\n\n for (const key in params) {\n const encodedKey = encodeURIComponent(key);\n const arrValue = Array.isArray(params[key]) ? params[key] : [params[key]];\n\n for (let v of arrValue) {\n v = prepareQueryParamValue(v);\n if (v === null) {\n continue\n }\n result.push(encodedKey + \"=\" + v);\n }\n }\n\n return result.join(\"&\");\n}\n\n// encodes and normalizes the provided query param value.\nfunction prepareQueryParamValue(value: any): null|string {\n if (value === null || typeof value === \"undefined\") {\n return null;\n }\n\n if (value instanceof Date) {\n return encodeURIComponent(value.toISOString().replace(\"T\", \" \"));\n }\n\n if (typeof value === \"object\") {\n return encodeURIComponent(JSON.stringify(value));\n }\n\n return encodeURIComponent(value)\n}\n","import { ClientResponseError } from \"@/ClientResponseError\";\nimport { BaseService } from \"@/services/BaseService\";\nimport { SendOptions, normalizeUnknownQueryParams } from \"@/tools/options\";\n\ninterface promiseCallbacks {\n resolve: Function;\n reject: Function;\n}\n\ntype Subscriptions = { [key: string]: Array<EventListener> };\n\nexport type UnsubscribeFunc = () => Promise<void>;\n\nexport class RealtimeService extends BaseService {\n clientId: string = \"\";\n\n private eventSource: EventSource | null = null;\n private subscriptions: Subscriptions = {};\n private lastSentSubscriptions: Array<string> = [];\n private connectTimeoutId: any;\n private maxConnectTimeout: number = 15000;\n private reconnectTimeoutId: any;\n private reconnectAttempts: number = 0;\n private maxReconnectAttempts: number = Infinity;\n private predefinedReconnectIntervals: Array<number> = [\n 200, 300, 500, 1000, 1200, 1500, 2000,\n ];\n private pendingConnects: Array<promiseCallbacks> = [];\n\n /**\n * Returns whether the realtime connection has been established.\n */\n get isConnected(): boolean {\n return !!this.eventSource && !!this.clientId && !this.pendingConnects.length;\n }\n\n /**\n * An optional hook that is invoked when the realtime client disconnects\n * either when unsubscribing from all subscriptions or when the\n * connection was interrupted or closed by the server.\n *\n * The received argument could be used to determine whether the disconnect\n * is a result from unsubscribing (`activeSubscriptions.length == 0`)\n * or because of network/server error (`activeSubscriptions.length > 0`).\n *\n * If you want to listen for the opposite, aka. when the client connection is established,\n * subscribe to the `PB_CONNECT` event.\n */\n onDisconnect?: (activeSubscriptions: Array<string>) => void;\n\n /**\n * Register the subscription listener.\n *\n * You can subscribe multiple times to the same topic.\n *\n * If the SSE connection is not started yet,\n * this method will also initialize it.\n */\n async subscribe(\n topic: string,\n callback: (data: any) => void,\n options?: SendOptions,\n ): Promise<UnsubscribeFunc> {\n if (!topic) {\n throw new Error(\"topic must be set.\");\n }\n\n let key = topic;\n\n // serialize and append the topic options (if any)\n if (options) {\n options = Object.assign({}, options); // shallow copy\n normalizeUnknownQueryParams(options);\n const serialized =\n \"options=\" +\n encodeURIComponent(\n JSON.stringify({ query: options.query, headers: options.headers }),\n );\n key += (key.includes(\"?\") ? \"&\" : \"?\") + serialized;\n }\n\n const listener = function (e: Event) {\n const msgEvent = e as MessageEvent;\n\n let data;\n try {\n data = JSON.parse(msgEvent?.data);\n } catch {}\n\n callback(data || {});\n };\n\n // store the listener\n if (!this.subscriptions[key]) {\n this.subscriptions[key] = [];\n }\n this.subscriptions[key].push(listener);\n\n if (!this.isConnected) {\n // initialize sse connection\n await this.connect();\n } else if (this.subscriptions[key].length === 1) {\n // send the updated subscriptions (if it is the first for the key)\n await this.submitSubscriptions();\n } else {\n // only register the listener\n this.eventSource?.addEventListener(key, listener);\n }\n\n return async (): Promise<void> => {\n return this.unsubscribeByTopicAndListener(topic, listener);\n };\n }\n\n /**\n * Unsubscribe from all subscription listeners with the specified topic.\n *\n * If `topic` is not provided, then this method will unsubscribe\n * from all active subscriptions.\n *\n * This method is no-op if there are no active subscriptions.\n *\n * The related sse connection will be autoclosed if after the\n * unsubscribe operation there are no active subscriptions left.\n */\n async unsubscribe(topic?: string): Promise<void> {\n let needToSubmit = false;\n\n if (!topic) {\n // remove all subscriptions\n this.subscriptions = {};\n } else {\n // remove all listeners related to the topic\n const subs = this.getSubscriptionsByTopic(topic);\n for (let key in subs) {\n if (!this.hasSubscriptionListeners(key)) {\n continue; // already unsubscribed\n }\n\n for (let listener of this.subscriptions[key]) {\n this.eventSource?.removeEventListener(key, listener);\n }\n delete this.subscriptions[key];\n\n // mark for subscriptions change submit if there are no other listeners\n if (!needToSubmit) {\n needToSubmit = true;\n }\n }\n }\n\n if (!this.hasSubscriptionListeners()) {\n // no other active subscriptions -> close the sse connection\n this.disconnect();\n } else if (needToSubmit) {\n await this.submitSubscriptions();\n }\n }\n\n /**\n * Unsubscribe from all subscription listeners starting with the specified topic prefix.\n *\n * This method is no-op if there are no active subscriptions with the specified topic prefix.\n *\n * The related sse connection will be autoclosed if after the\n * unsubscribe operation there are no active subscriptions left.\n */\n async unsubscribeByPrefix(keyPrefix: string): Promise<void> {\n let hasAtleastOneTopic = false;\n for (let key in this.subscriptions) {\n // \"?\" so that it can be used as end delimiter for the prefix\n if (!(key + \"?\").startsWith(keyPrefix)) {\n continue;\n }\n\n hasAtleastOneTopic = true;\n for (let listener of this.subscriptions[key]) {\n this.eventSource?.removeEventListener(key, listener);\n }\n delete this.subscriptions[key];\n }\n\n if (!hasAtleastOneTopic) {\n return; // nothing to unsubscribe from\n }\n\n if (this.hasSubscriptionListeners()) {\n // submit the deleted subscriptions\n await this.submitSubscriptions();\n } else {\n // no other active subscriptions -> close the sse connection\n this.disconnect();\n }\n }\n\n /**\n * Unsubscribe from all subscriptions matching the specified topic and listener function.\n *\n * This method is no-op if there are no active subscription with\n * the specified topic and listener.\n *\n * The related sse connection will be autoclosed if after the\n * unsubscribe operation there are no active subscriptions left.\n */\n async unsubscribeByTopicAndListener(\n topic: string,\n listener: EventListener,\n ): Promise<void> {\n let needToSubmit = false;\n\n const subs = this.getSubscriptionsByTopic(topic);\n for (let key in subs) {\n if (\n !Array.isArray(this.subscriptions[key]) ||\n !this.subscriptions[key].length\n ) {\n continue; // already unsubscribed\n }\n\n let exist = false;\n for (let i = this.subscriptions[key].length - 1; i >= 0; i--) {\n if (this.subscriptions[key][i] !== listener) {\n continue;\n }\n\n exist = true; // has at least one matching listener\n delete this.subscriptions[key][i]; // removes the function reference\n this.subscriptions[key].splice(i, 1); // reindex the array\n this.eventSource?.removeEventListener(key, listener);\n }\n if (!exist) {\n continue;\n }\n\n // remove the key from the subscriptions list if there are no other listeners\n if (!this.subscriptions[key].length) {\n delete this.subscriptions[key];\n }\n\n // mark for subscriptions change submit if there are no other listeners\n if (!needToSubmit && !this.hasSubscriptionListeners(key)) {\n needToSubmit = true;\n }\n }\n\n if (!this.hasSubscriptionListeners()) {\n // no other active subscriptions -> close the sse connection\n this.disconnect();\n } else if (needToSubmit) {\n await this.submitSubscriptions();\n }\n }\n\n private hasSubscriptionListeners(keyToCheck?: string): boolean {\n this.subscriptions = this.subscriptions || {};\n\n // check the specified key\n if (keyToCheck) {\n return !!this.subscriptions[keyToCheck]?.length;\n }\n\n // check for at least one non-empty subscription\n for (let key in this.subscriptions) {\n if (!!this.subscriptions[key]?.length) {\n return true;\n }\n }\n\n return false;\n }\n\n private async submitSubscriptions(): Promise<void> {\n if (!this.clientId) {\n return; // no client/subscriber\n }\n\n // optimistic update\n this.addAllSubscriptionListeners();\n\n this.lastSentSubscriptions = this.getNonEmptySubscriptionKeys();\n\n return this.client\n .send(\"/api/realtime\", {\n method: \"POST\",\n body: {\n clientId: this.clientId,\n subscriptions: this.lastSentSubscriptions,\n },\n requestKey: this.getSubscriptionsCancelKey(),\n })\n .catch((err) => {\n if (err?.isAbort) {\n return; // silently ignore aborted pending requests\n }\n throw err;\n });\n }\n\n private getSubscriptionsCancelKey(): string {\n return \"realtime_\" + this.clientId;\n }\n\n private getSubscriptionsByTopic(topic: string): Subscriptions {\n const result: Subscriptions = {};\n\n // \"?\" so that it can be used as end delimiter for the topic\n topic = topic.includes(\"?\") ? topic : topic + \"?\";\n\n for (let key in this.subscriptions) {\n if ((key + \"?\").startsWith(topic)) {\n result[key] = this.subscriptions[key];\n }\n }\n\n return result;\n }\n\n private getNonEmptySubscriptionKeys(): Array<string> {\n const result: Array<string> = [];\n\n for (let key in this.subscriptions) {\n if (this.subscriptions[key].length) {\n result.push(key);\n }\n }\n\n return result;\n }\n\n private addAllSubscriptionListeners(): void {\n if (!this.eventSource) {\n return;\n }\n\n this.removeAllSubscriptionListeners();\n\n for (let key in this.subscriptions) {\n for (let listener of this.subscriptions[key]) {\n this.eventSource.addEventListener(key, listener);\n }\n }\n }\n\n private removeAllSubscriptionListeners(): void {\n if (!this.eventSource) {\n return;\n }\n\n for (let key in this.subscriptions) {\n for (let listener of this.subscriptions[key]) {\n this.eventSource.removeEventListener(key, listener);\n }\n }\n }\n\n private async connect(): Promise<void> {\n if (this.reconnectAttempts > 0) {\n // immediately resolve the promise to avoid indefinitely\n // blocking the client during reconnection\n return;\n }\n\n return new Promise((resolve, reject) => {\n this.pendingConnects.push({ resolve, reject });\n\n if (this.pendingConnects.length > 1) {\n // all promises will be resolved once the connection is established\n return;\n }\n\n this.initConnect();\n });\n }\n\n private initConnect() {\n this.disconnect(true);\n\n // wait up to 15s for connect\n clearTimeout(this.connectTimeoutId);\n this.connectTimeoutId = setTimeout(() => {\n this.connectErrorHandler(new Error(\"EventSource connect took too long.\"));\n }, this.maxConnectTimeout);\n\n this.eventSource = new EventSource(this.client.buildURL(\"/api/realtime\"));\n\n this.eventSource.onerror = (_) => {\n this.connectErrorHandler(\n new Error(\"Failed to establish realtime connection.\"),\n );\n };\n\n this.eventSource.addEventListener(\"PB_CONNECT\", (e) => {\n const msgEvent = e as MessageEvent;\n this.clientId = msgEvent?.lastEventId;\n\n this.submitSubscriptions()\n .then(async () => {\n let retries = 3;\n while (this.hasUnsentSubscriptions() && retries > 0) {\n retries--;\n // resubscribe to ensure that the latest topics are submitted\n //\n // This is needed because missed topics could happen on reconnect\n // if after the pending sent `submitSubscriptions()` call another `subscribe()`\n // was made before the submit was able to complete.\n await this.submitSubscriptions();\n }\n })\n .then(() => {\n for (let p of this.pendingConnects) {\n p.resolve();\n }\n\n // reset connect meta\n this.pendingConnects = [];\n this.reconnectAttempts = 0;\n clearTimeout(this.reconnectTimeoutId);\n clearTimeout(this.connectTimeoutId);\n\n // propagate the PB_CONNECT event\n const connectSubs = this.getSubscriptionsByTopic(\"PB_CONNECT\");\n for (let key in connectSubs) {\n for (let listener of connectSubs[key]) {\n listener(e);\n }\n }\n })\n .catch((err) => {\n this.clientId = \"\";\n this.connectErrorHandler(err);\n });\n });\n }\n\n private hasUnsentSubscriptions(): boolean {\n const latestTopics = this.getNonEmptySubscriptionKeys();\n if (latestTopics.length != this.lastSentSubscriptions.length) {\n return true;\n }\n\n for (const t of latestTopics) {\n if (!this.lastSentSubscriptions.includes(t)) {\n return true;\n }\n }\n\n return false;\n }\n\n private connectErrorHandler(err: any) {\n clearTimeout(this.connectTimeoutId);\n clearTimeout(this.reconnectTimeoutId);\n\n if (\n // wasn't previously connected -> direct reject\n (!this.clientId && !this.reconnectAttempts) ||\n // was previously connected but the max reconnection limit has been reached\n this.reconnectAttempts > this.maxReconnectAttempts\n ) {\n for (let p of this.pendingConnects) {\n p.reject(new ClientResponseError(err));\n }\n this.pendingConnects = [];\n this.disconnect();\n return;\n }\n\n // otherwise -> reconnect in the background\n this.disconnect(true);\n const timeout =\n this.predefinedReconnectIntervals[this.reconnectAttempts] ||\n this.predefinedReconnectIntervals[\n this.predefinedReconnectIntervals.l