Files
hoppscotch/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts
2024-02-06 20:35:29 +05:30

267 lines
7.8 KiB
TypeScript

import * as E from "fp-ts/Either"
import { AxiosRequestConfig } from "axios"
import { Service } from "dioc"
import { getI18n } from "~/modules/i18n"
import {
Interceptor,
InterceptorError,
RequestRunResult,
} from "~/services/interceptor.service"
import { cloneDeep } from "lodash-es"
import { computed, readonly, ref } from "vue"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import SettingsExtension from "~/components/settings/Extension.vue"
import InterceptorsExtensionSubtitle from "~/components/interceptors/ExtensionSubtitle.vue"
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
import { until } from "@vueuse/core"
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
...obj,
_subscribers: {} as {
// eslint-disable-next-line no-unused-vars
[key in keyof T]?: ((...args: any[]) => any)[]
},
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
if (Array.isArray(this._subscribers[prop])) {
this._subscribers[prop]?.push(func)
} else {
this._subscribers[prop] = [func]
}
},
}
type SubscribableProxyObject = typeof proxyObject
return new Proxy(proxyObject, {
set(obj, prop, newVal) {
obj[prop as keyof SubscribableProxyObject] = newVal
const currentSubscribers = obj._subscribers[prop as keyof T]
if (Array.isArray(currentSubscribers)) {
for (const subscriber of currentSubscribers) {
subscriber(newVal)
}
}
return true
},
})
}
// TODO: Rework this to deal with individual requests rather than cancel all
export const cancelRunningExtensionRequest = () => {
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest()
}
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents marshalling issues with structured cloning of URLSearchParams
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
export type ExtensionStatus = "available" | "unknown-origin" | "waiting"
/**
* This service is responsible for defining the extension interceptor.
*/
export class ExtensionInterceptorService
extends Service
implements Interceptor
{
public static readonly ID = "EXTENSION_INTERCEPTOR_SERVICE"
private _extensionStatus = ref<ExtensionStatus>("waiting")
/**
* The status of the extension, whether it's available, or not.
*/
public extensionStatus = readonly(this._extensionStatus)
/**
* The version of the extension, if available.
*/
public extensionVersion = computed(() => {
if (this.extensionStatus.value === "available") {
return window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
}
return null
})
/**
* Whether the extension is installed in Chrome or not.
*/
public chromeExtensionInstalled = computed(
() => this.extensionStatus.value === "available" && browserIsChrome()
)
/**
* Whether the extension is installed in Firefox or not.
*/
public firefoxExtensionInstalled = computed(
() => this.extensionStatus.value === "available" && browserIsFirefox()
)
public interceptorID = "extension"
public settingsPageEntry: Interceptor["settingsPageEntry"] = {
entryTitle: (t) => t("settings.extensions"),
component: SettingsExtension,
}
public selectorSubtitle = InterceptorsExtensionSubtitle
public selectable = { type: "selectable" as const }
constructor() {
super()
this.listenForExtensionStatus()
}
private listenForExtensionStatus() {
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
this._extensionStatus.value =
window.__HOPP_EXTENSION_STATUS_PROXY__.status
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
"status",
(status: ExtensionStatus) => {
this._extensionStatus.value = status
}
)
} else {
const statusProxy = defineSubscribableObject({
status: "waiting" as ExtensionStatus,
})
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
statusProxy.subscribe(
"status",
(status: ExtensionStatus) => (this._extensionStatus.value = status)
)
/**
* Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
* then we use a poll to find the version, this will get the version for 0.24 and any other version
* of the extension, but will have a slight lag.
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/
extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
if (extensionPollIntervalId.value)
clearInterval(extensionPollIntervalId.value)
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
if (
version.major === 0 &&
version.minor <= 23 &&
window.__HOPP_EXTENSION_STATUS_PROXY__
) {
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
}
}
}, 2000)
}
}
public name(t: ReturnType<typeof getI18n>) {
return computed(() => {
const version = window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
if (this.extensionStatus.value === "available" && version) {
const { major, minor } = version
return `${t("settings.extensions")}: v${major}.${minor}`
}
return `${t("settings.extensions")}: ${t(
"settings.extension_ver_not_reported"
)}`
})
}
private async runRequestOnExtension(
req: AxiosRequestConfig
): RequestRunResult["response"] {
// wait for the extension to resolve
await until(this.extensionStatus).toMatch(
(status) => status !== "waiting",
{
timeout: 1000,
}
)
const extensionHook = window.__POSTWOMAN_EXTENSION_HOOK__
if (!extensionHook) {
return E.left(<InterceptorError>{
// TODO: i18n this
humanMessage: {
heading: () => "Extension not found",
description: () => "Heading not found",
},
error: "NO_PW_EXT_HOOK",
component: InterceptorsErrorPlaceholder,
})
}
try {
const result = await extensionHook.sendRequest({
...req,
wantsBinary: true,
})
return E.right(result)
} catch (e) {
console.error(e)
// TODO: improve type checking
if ((e as any).response) {
return E.right((e as any).response)
}
return E.left(<InterceptorError>{
// TODO: i18n this
humanMessage: {
heading: () => "Extension error",
description: () => "Failed running request on extension",
},
error: e,
})
}
}
public runRequest(
request: AxiosRequestConfig
): RequestRunResult<InterceptorError> {
const processedReq = preProcessRequest(request)
return {
cancel: cancelRunningExtensionRequest,
response: this.runRequestOnExtension(processedReq),
}
}
}