From dd3bf5271146d676b5a5305c43446632540a6f50 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Mon, 10 Mar 2025 22:45:51 +0530 Subject: [PATCH] feat(kernel): isolate type ser/de to platform (#4860) --- .../hoppscotch-agent/src-tauri/Cargo.lock | 2 +- .../src-tauri/tauri.conf.json | 2 +- packages/hoppscotch-agent/vite.config.ts | 2 +- .../components/settings/ExtensionSubtitle.vue | 19 +- .../src/helpers/kernel/common/content.ts | 85 +------ .../hoppscotch-common/src/helpers/network.ts | 4 - .../std/kernel-interceptors/agent/index.ts | 5 +- .../std/kernel-interceptors/agent/store.ts | 10 +- .../kernel-interceptors/extension/index.ts | 213 ++++++++++++++++-- .../kernel-interceptors/extension/store.ts | 52 +++-- .../std/kernel-interceptors/native/index.ts | 5 - .../std/kernel-interceptors/proxy/index.ts | 206 ++++++++++++----- .../plugin-workspace/relay/src/content.rs | 2 +- .../plugin-workspace/relay/src/interop.rs | 5 +- .../tauri-plugin-relay/Cargo.lock | 2 +- .../hoppscotch-desktop/src-tauri/Cargo.lock | 4 +- packages/hoppscotch-kernel/src/index.ts | 4 +- .../src/relay/impl/desktop/v/1.ts | 44 ++-- .../src/relay/impl/web/v/1.ts | 2 - packages/hoppscotch-kernel/src/relay/v/1.ts | 68 +++++- pnpm-lock.yaml | 8 +- 21 files changed, 510 insertions(+), 234 deletions(-) diff --git a/packages/hoppscotch-agent/src-tauri/Cargo.lock b/packages/hoppscotch-agent/src-tauri/Cargo.lock index 30c19aed..9d985aff 100644 --- a/packages/hoppscotch-agent/src-tauri/Cargo.lock +++ b/packages/hoppscotch-agent/src-tauri/Cargo.lock @@ -4020,7 +4020,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relay" version = "0.1.1" -source = "git+https://github.com/CuriousCorrelation/relay.git#d258a2c1557b9da0715681a1f267a686eb4920bb" +source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4" dependencies = [ "bytes", "curl", diff --git a/packages/hoppscotch-agent/src-tauri/tauri.conf.json b/packages/hoppscotch-agent/src-tauri/tauri.conf.json index 634fcca0..f1641a2d 100644 --- a/packages/hoppscotch-agent/src-tauri/tauri.conf.json +++ b/packages/hoppscotch-agent/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "io.hoppscotch.agent", "build": { "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://127.0.0.1:1420", "beforeBuildCommand": "pnpm build", "frontendDist": "../dist" }, diff --git a/packages/hoppscotch-agent/vite.config.ts b/packages/hoppscotch-agent/vite.config.ts index bcba9610..002646ef 100644 --- a/packages/hoppscotch-agent/vite.config.ts +++ b/packages/hoppscotch-agent/vite.config.ts @@ -43,7 +43,7 @@ export default defineConfig(async () => ({ server: { port: 1420, strictPort: true, - host: host || false, + host: '127.0.0.1', hmr: host ? { protocol: "ws", diff --git a/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue b/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue index ac211eb8..057528f8 100644 --- a/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue +++ b/packages/hoppscotch-common/src/components/settings/ExtensionSubtitle.vue @@ -4,10 +4,10 @@ class="flex flex-col items-left my-2 text-secondaryLight" >
- + {{ - `${t("settings.extension_version")}: v${extensionVersion.value.major}.${ - extensionVersion.value.minor + `${t("settings.extension_version")}: v${extensionVersion.major}.${ + extensionVersion.minor }` }} @@ -40,16 +40,17 @@ diff --git a/packages/hoppscotch-common/src/helpers/kernel/common/content.ts b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts index 1aa54629..f80b5f4c 100644 --- a/packages/hoppscotch-common/src/helpers/kernel/common/content.ts +++ b/packages/hoppscotch-common/src/helpers/kernel/common/content.ts @@ -1,20 +1,12 @@ import * as E from "fp-ts/Either" import * as TE from "fp-ts/TaskEither" import * as O from "fp-ts/Option" -import * as A from "fp-ts/Array" import { pipe } from "fp-ts/function" import { parseJSONAs } from "~/helpers/functional/json" import { ContentType, MediaType, content } from "@hoppscotch/kernel" import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" -type FormDataValue = { - kind: "file" - filename: string - contentType: string - data: Uint8Array -} - const Processors = { json: { process: (body: string): E.Either => @@ -25,78 +17,6 @@ const Processors = { ), }, - multipart: { - processFile: (entry: { - key: string - file: Blob - contentType?: string - }): TE.TaskEither => - pipe( - TE.tryCatch( - () => entry.file.arrayBuffer(), - () => new Error("File read failed") - ), - TE.map((buffer) => ({ - key: entry.key, - value: [ - { - kind: "file", - filename: - entry.file instanceof File ? entry.file.name : "unknown", - contentType: - entry.contentType ?? - (entry.file instanceof File - ? entry.file.type - : "application/octet-stream"), - data: new Uint8Array(buffer), - }, - ], - })) - ), - - process: (formData: FormData): TE.TaskEither => - pipe( - TE.tryCatch( - async () => { - const entries = [] as { - key: string - file: Blob - contentType?: string - }[] - // @ts-expect-error: `formData.entries` does exist but isn't visible, - // see `"lib": ["ESNext", "DOM"],` in `tsconfig.json` - for (const [key, value] of formData.entries()) { - if (value instanceof Blob) { - entries.push({ - key, - file: value, - contentType: value.type || undefined, - }) - } - } - return entries - }, - () => new Error("FormData processing failed") - ), - TE.chain((entries) => - pipe( - entries, - A.traverse(TE.ApplicativePar)(Processors.multipart.processFile), - TE.map( - A.reduce( - new Map(), - (acc, { key, value }) => { - acc.set(key, value) - return acc - } - ) - ) - ) - ), - TE.map((entries) => content.multipart(entries)) - ), - }, - binary: { process: (file: Blob): TE.TaskEither => pipe( @@ -169,10 +89,7 @@ export const transformContent = ( if (!(effectiveFinalBody instanceof FormData)) { return TE.right(O.none) } - return pipe( - Processors.multipart.process(effectiveFinalBody), - TE.map(O.some) - ) + return TE.right(O.some(content.multipart(effectiveFinalBody))) case "application/octet-stream": if (!(effectiveFinalBody instanceof Blob)) { diff --git a/packages/hoppscotch-common/src/helpers/network.ts b/packages/hoppscotch-common/src/helpers/network.ts index d8808be9..b855d3b8 100644 --- a/packages/hoppscotch-common/src/helpers/network.ts +++ b/packages/hoppscotch-common/src/helpers/network.ts @@ -22,11 +22,7 @@ export function createRESTNetworkRequestStream( const req = cloneDeep(request) - console.info("[helpers/network]: req", req) - const execResult = RESTRequest.toRequest(req).then((kernelRequest) => { - console.info("[helpers/network]: kernelRequest", kernelRequest) - if (!kernelRequest) { response.next({ type: "network_fail", diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts index 4ed1acbc..292469b1 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/index.ts @@ -1,6 +1,6 @@ import { Service } from "dioc" import { markRaw } from "vue" -import { body } from "@hoppscotch/kernel" +import { body, relayRequestToNativeAdapter } from "@hoppscotch/kernel" import * as E from "fp-ts/Either" import { pipe } from "fp-ts/function" import axios, { CancelTokenSource } from "axios" @@ -132,9 +132,8 @@ export class AgentKernelInterceptorService .join(";") } - console.log("[AGENT]: effectiveRequest", effectiveRequest) const [nonceB16, encryptedReq] = await this.store.encryptRequest( - effectiveRequest, + await relayRequestToNativeAdapter(effectiveRequest), reqID ) diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts index e5e804d7..dea8d788 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts @@ -4,7 +4,7 @@ import * as E from "fp-ts/Either" import axios from "axios" import superjson from "superjson" import { Store } from "~/kernel/store" -import type { RelayRequest, RelayResponse } from "@hoppscotch/kernel" +import type { PluginRequest, PluginResponse } from "@hoppscotch/kernel" import { x25519 } from "@noble/curves/ed25519" import { base16 } from "@scure/base" import { @@ -156,8 +156,8 @@ export class KernelInterceptorAgentStore extends Service { } public completeRequest( - request: Omit - ): RelayRequest { + request: Omit + ): PluginRequest { const host = new URL(request.url).host const settings = this.getMergedSettings(host) const effective = convertDomainSetting(settings) @@ -258,7 +258,7 @@ export class KernelInterceptorAgentStore extends Service { } public async encryptRequest( - request: RelayRequest, + request: PluginRequest, reqID: number ): Promise<[string, ArrayBuffer]> { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -291,7 +291,7 @@ export class KernelInterceptorAgentStore extends Service { public async decryptResponse( nonceB16: string, encryptedResponse: ArrayBuffer - ): Promise { + ): Promise { const nonce = base16.decode(nonceB16.toUpperCase()) const sharedSecretKeyBytes = base16.decode( this.sharedSecretB16.value!.toUpperCase() diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts index af164840..7a733e2f 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/index.ts @@ -1,20 +1,56 @@ -import { computed, markRaw } from "vue" +import { computed, markRaw, ref } from "vue" import { Service } from "dioc" import type { RelayRequest, RelayResponse } from "@hoppscotch/kernel" import { body } from "@hoppscotch/kernel" import SettingsExtension from "~/components/settings/Extension.vue" import SettingsExtensionSubtitle from "~/components/settings/ExtensionSubtitle.vue" -import { KernelInterceptorExtensionStore } from "./store" -import type { - KernelInterceptor, - ExecutionResult, - KernelInterceptorError, -} from "~/services/kernel-interceptor.service" import * as E from "fp-ts/Either" import { getI18n } from "~/modules/i18n" import { until } from "@vueuse/core" import { preProcessRelayRequest } from "~/helpers/functional/preprocess" import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent" +import type { + KernelInterceptor, + ExecutionResult, + KernelInterceptorError, +} from "~/services/kernel-interceptor.service" + +export const defineSubscribableObject = (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 + }, + }) +} + +export type ExtensionStatus = "available" | "unknown-origin" | "waiting" export const cancelRunningExtensionRequest = async () => { window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() @@ -25,7 +61,8 @@ export class ExtensionKernelInterceptorService implements KernelInterceptor { public static readonly ID = "KERNEL_EXTENSION_INTERCEPTOR_SERVICE" - private readonly store = this.bind(KernelInterceptorExtensionStore) + + private _extensionStatus = ref("waiting") public readonly id = "extension" public readonly name = (t: ReturnType) => { @@ -34,7 +71,7 @@ export class ExtensionKernelInterceptorService if (this.extensionStatus.value === "available" && version) { return `${t("settings.extensions")}: v${version.major}.${version.minor}` } - return `${t("settings.extensions")}: ${t("settings.extension_ver_not_reported")}` + return `${t("settings.extensions")}` } public readonly selectable = { type: "selectable" as const } @@ -71,6 +108,15 @@ export class ExtensionKernelInterceptorService advanced: new Set(["localaccess"]), } as const + public readonly extensionStatus = computed(() => this._extensionStatus.value) + + public readonly extensionVersion = computed(() => { + if (this.extensionStatus.value === "available") { + return window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() || null + } + return null + }) + public readonly settingsEntry = markRaw({ title: (t: ReturnType) => t("settings.extensions"), component: SettingsExtension, @@ -78,14 +124,6 @@ export class ExtensionKernelInterceptorService public readonly subtitle = markRaw(SettingsExtensionSubtitle) - public readonly extensionStatus = computed( - () => this.store.getSettings().status - ) - - public readonly extensionVersion = computed( - () => this.store.getSettings().extensionVersion - ) - /** * Whether the extension is installed in Chrome or not. */ @@ -100,10 +138,80 @@ export class ExtensionKernelInterceptorService () => this.extensionStatus.value === "available" && browserIsFirefox() ) + override async onServiceInit(): Promise { + this.setupExtensionStatusListener() + } + + private setupExtensionStatusListener(): void { + const extensionPollIntervalId = ref>() + + 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) + ) + + // Check if extension is already available + if (this.tryDetectExtension()) { + return + } + + /** + * 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 (this.tryDetectExtension() && extensionPollIntervalId.value) { + clearInterval(extensionPollIntervalId.value) + } + }, 2000) + } + } + + public tryDetectExtension(): boolean { + if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { + const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() + + this._extensionStatus.value = "available" + + // 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" + } + return true + } + return false + } + private async executeExtensionRequest( request: RelayRequest ): Promise> { - await until(() => this.store.getSettings().status).not.toBe("waiting") + // Wait for the extension to resolve (not waiting forever) + await until(this.extensionStatus).toMatch( + (status) => status !== "waiting", + { timeout: 1000 } + ) if (!window.__POSTWOMAN_EXTENSION_HOOK__) { return E.left({ @@ -119,12 +227,57 @@ export class ExtensionKernelInterceptorService } try { + let requestData: any = null + + if (request.content) { + switch (request.content.kind) { + case "json": + // For JSON, we need to stringify it before sending it to extension, + // see extension source code for more info on this. + // Also if it's already a string, we can use it as is, otherwise we stringify. + requestData = + typeof request.content.content === "string" + ? request.content.content + : JSON.stringify(request.content.content) + break + + case "binary": + if ( + request.content.content instanceof Blob || + request.content.content instanceof File + ) { + requestData = request.content.content + } else if (typeof request.content.content === "string") { + try { + const base64 = + request.content.content.split(",")[1] || + request.content.content + const binaryString = window.atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + requestData = new Blob([bytes.buffer]) + } catch (e) { + console.error("Error converting binary data:", e) + requestData = request.content.content + } + } else { + requestData = request.content.content + } + break + + default: + requestData = request.content.content + } + } + const extensionResponse = await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ url: request.url, method: request.method, headers: request.headers, - data: request.content?.content, + data: requestData, wantsBinary: true, }) @@ -168,6 +321,28 @@ export class ExtensionKernelInterceptorService public execute( request: RelayRequest ): ExecutionResult { + if (this._extensionStatus.value !== "available") { + return { + cancel: async () => { + // Nothing to cancel if extension is not available + }, + response: Promise.resolve( + E.left({ + humanMessage: { + heading: (t: ReturnType) => + t("error.extension.heading"), + description: (t: ReturnType) => + t("error.extension.description"), + }, + error: { + kind: "extension", + message: "Extension not available", + }, + }) + ), + } + } + const extensionExecution = { cancel: async () => { window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts index 51a6dc53..9e8fe28c 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/extension/store.ts @@ -83,7 +83,10 @@ export class KernelInterceptorExtensionStore extends Service { } await this.loadSettings() - this.setupExtensionStatusListener() + + if (!this.tryDetectExtension()) { + this.setupExtensionStatusListener() + } Store.watch(STORE_NAMESPACE, SETTINGS_KEY).on( "change", @@ -123,6 +126,10 @@ export class KernelInterceptorExtensionStore extends Service { this.updateSettings({ status }) ) + if (this.tryDetectExtension()) { + return + } + /** * Keeping identifying extension backward compatible * We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately, @@ -131,26 +138,39 @@ export class KernelInterceptorExtensionStore extends Service { * 0.24 users will get the benefits of 0.24, while the extension won't break for the old users */ this.extensionPollIntervalId.value = setInterval(() => { - if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { - if (this.extensionPollIntervalId.value) - clearInterval(this.extensionPollIntervalId.value) - - const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() - this.updateSettings({ extensionVersion: version }) - - // 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" - } + if (this.tryDetectExtension() && this.extensionPollIntervalId.value) { + clearInterval(this.extensionPollIntervalId.value) } }, 2000) } } + public tryDetectExtension(): boolean { + if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { + const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion() + + this.settings = { + ...this.settings, + extensionVersion: version, + status: "available", + } + + // We don't need to persist right away to thing eventually resolving is good enough + this.persistSettings() + + // 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" + } + return true + } + return false + } + private async loadSettings(): Promise { const loadResult = await Store.get( STORE_NAMESPACE, diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts index cf419c8a..b356b899 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/index.ts @@ -83,11 +83,6 @@ export class NativeKernelInterceptorService .join(";") } - console.info( - "[platform/std/kernel-interceptor/native/index]: effectiveRequest", - effectiveRequest - ) - const relayExecution = Relay.execute(effectiveRequest) const response = pipe(relayExecution.response, (promise) => diff --git a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts index 66e3470c..6be081f4 100644 --- a/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts +++ b/packages/hoppscotch-common/src/platform/std/kernel-interceptors/proxy/index.ts @@ -1,6 +1,5 @@ import { markRaw } from "vue" import type { - FormDataValue, RelayRequest, ContentType, Method, @@ -17,10 +16,14 @@ import type { KernelInterceptorError, } from "~/services/kernel-interceptor.service" import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" import { pipe } from "fp-ts/function" import { getI18n } from "~/modules/i18n" import { v4 } from "uuid" + import { preProcessRelayRequest } from "~/helpers/functional/preprocess" +import { parseBytesToJSON } from "~/helpers/functional/json" +import { decodeB64StringToArrayBuffer } from "~/helpers/utils/b64" type ProxyRequest = { url: string @@ -83,32 +86,56 @@ export class ProxyKernelInterceptorService request: RelayRequest, accessToken: string ): ProxyRequest { - let wantsBinary = false - let requestData = "" + // NOTE: This should be conditional but for now setting it to true for backwards compat, + // see std/interceptor/proxy.ts for more info. + const wantsBinary = true + let requestData: any = null + + // This is required for backwards compatibility with current proxyscotch impl if (request.content) { - if (request.content.kind === "text" || request.content.kind === "xml") { - requestData = request.content.content - } else if (request.content.kind === "json") { - requestData = JSON.stringify(request.content.content) - } else if ( - request.content.kind === "multipart" || - request.content.kind === "form" - ) { - wantsBinary = true - const formData = new FormData() - request.content.content.forEach( - (values: FormDataValue[], key: string) => { - values.forEach((value: FormDataValue) => { - if (value.kind === "text") { - formData.append(key, value.value) - } else { - const blob = new Blob([value.data], { type: value.contentType }) - formData.append(key, blob, value.filename) + switch (request.content.kind) { + case "json": + requestData = + typeof request.content.content === "string" + ? request.content.content + : JSON.stringify(request.content.content) + break + + case "binary": + if ( + request.content.content instanceof Blob || + request.content.content instanceof File + ) { + requestData = request.content.content + } else if (typeof request.content.content === "string") { + // This is rather rare but just in case + try { + const base64 = + request.content.content.split(",")[1] || request.content.content + const binaryString = window.atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) } - }) + requestData = new Blob([bytes.buffer]) + } catch (e) { + console.error("Error converting binary data:", e) + requestData = request.content.content + } + } else { + requestData = request.content.content } - ) - requestData = formData.toString() + break + + case "multipart": + // `multipart` has separate handling in `execute`, + // where we combine request with request body + // so removing that part right now + requestData = "" + break + + default: + requestData = request.content.content } } @@ -137,15 +164,51 @@ export class ProxyKernelInterceptorService const accessToken = settings.accessToken const proxyUrl = settings.proxyUrl - const proxyRequest = this.constructProxyRequest( - preProcessRelayRequest(request), - accessToken - ) + const processedRequest = preProcessRelayRequest(request) - const content: ContentType = { - kind: "json", - content: proxyRequest, - mediaType: MediaType.APPLICATION_JSON, + let content: ContentType + const multipartKey = `proxyRequestData-${v4()}` + + if ( + processedRequest.content && + processedRequest.content.kind === "multipart" && + processedRequest.content.content instanceof FormData + ) { + const modifiedRequest = { ...processedRequest } + + const proxyRequest = this.constructProxyRequest( + modifiedRequest, + accessToken + ) + + const formData = processedRequest.content.content as FormData + const newFormData = new FormData() + + // @ts-expect-error: `formData.entries` does exist but isn't visible, + // see `"lib": ["ESNext", "DOM"],` in `tsconfig.json` + for (const [key, value] of formData.entries()) { + newFormData.append(key, value) + } + + const proxyRequestString = JSON.stringify(proxyRequest) + newFormData.append(multipartKey, proxyRequestString) + + content = { + kind: "multipart", + content: newFormData, + mediaType: MediaType.MULTIPART_FORM_DATA, + } + } else { + const proxyRequest = this.constructProxyRequest( + processedRequest, + accessToken + ) + + content = { + kind: "json", + content: proxyRequest, + mediaType: MediaType.APPLICATION_JSON, + } } const proxyRelayRequest: RelayRequest = { @@ -157,7 +220,7 @@ export class ProxyKernelInterceptorService "content-type": content.mediaType, ...(content.kind === "multipart" ? { - "multipart-part-key": `proxyRequestData-${v4()}`, + "multipart-part-key": multipartKey, } : {}), }, @@ -236,19 +299,9 @@ export class ProxyKernelInterceptorService return { humanMessage, error } }), E.chain((res) => { - const proxyBody = - res.body.mediaType === MediaType.TEXT_PLAIN - ? new Uint8Array(res.body.body) - : null + const proxyResponse = parseBytesToJSON(res.body.body) - // NOTE: This will become obsolete if we use native interceptor like error propagation. - const proxyResponse = proxyBody - ? (JSON.parse( - new TextDecoder().decode(proxyBody) - ) as ProxyResponse) - : null - - if (!proxyResponse?.success) { + if (O.isNone(proxyResponse)) { return E.left({ humanMessage: { heading: (t) => t("error.network.heading"), @@ -265,13 +318,59 @@ export class ProxyKernelInterceptorService }) } - if (proxyResponse.isBinary) { + const parsedProxyResponse = proxyResponse.value + + if (!parsedProxyResponse?.success) { + return E.left({ + humanMessage: { + heading: (t) => t("error.network.heading"), + description: (t) => + t("error.network.description", { + message: "Proxy request failed", + cause: "Proxy server may be unresponsive", + }), + }, + error: { + kind: "network", + message: "Proxy request failed", + }, + }) + } + + // NOTE: This should be conditional but seems to be hit always, + // see std/interceptor/proxy.ts for more info. Also see the above similar note. + if (parsedProxyResponse.isBinary) { + const decodedData = decodeB64StringToArrayBuffer( + parsedProxyResponse.data + ) + + // NOTE: This is also for backwards compat, + // better solution would be to ask for raw bytes from proxyscotch. + const jsonResult = parseBytesToJSON(new Uint8Array(decodedData)) + + if (O.isSome(jsonResult)) { + return E.right({ + ...res, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, + body: { + body: new TextEncoder().encode( + JSON.stringify(jsonResult.value) + ), + mediaType: "application/json", + }, + }) + } return E.right({ ...res, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, body: { - body: proxyResponse.data, + body: decodedData, mediaType: - proxyResponse.headers["content-type"] || + parsedProxyResponse.headers["content-type"] || "application/octet-stream", }, }) @@ -279,12 +378,13 @@ export class ProxyKernelInterceptorService return E.right({ ...res, - status: proxyResponse.status, - statusText: proxyResponse.statusText, - headers: proxyResponse.headers, + status: parsedProxyResponse.status, + statusText: parsedProxyResponse.statusText, + headers: parsedProxyResponse.headers, body: { - body: new TextEncoder().encode(proxyResponse.data), - mediaType: "text/plain", + body: parsedProxyResponse.data, + mediaType: + parsedProxyResponse.headers["content-type"] || "text/plain", }, }) }) diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs index 659becbb..689833ea 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/content.rs @@ -201,7 +201,7 @@ impl<'a> ContentHandler<'a> { for (key, values) in content { for value in values { match value { - FormValue::Text(text) => { + FormValue::Text { value: text } => { tracing::debug!(key = %key, text_length = text.len(), "Adding form text field"); form.part(key) .contents(text.as_bytes()) diff --git a/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs b/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs index 0a9e037a..8c94c2b2 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs +++ b/packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs @@ -48,7 +48,10 @@ pub enum MediaType { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FormValue { - Text(String), + #[serde(rename_all = "camelCase")] + Text { + value: String, + }, #[serde(rename_all = "camelCase")] File { filename: String, diff --git a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock index a226075d..30da483a 100644 --- a/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock +++ b/packages/hoppscotch-desktop/plugin-workspace/tauri-plugin-relay/Cargo.lock @@ -2812,7 +2812,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relay" version = "0.1.1" -source = "git+https://github.com/CuriousCorrelation/relay.git#0a314ed5b71c74349d55f8213d57afbbe55abb87" +source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4" dependencies = [ "bytes", "curl", diff --git a/packages/hoppscotch-desktop/src-tauri/Cargo.lock b/packages/hoppscotch-desktop/src-tauri/Cargo.lock index fd4ebc41..96a4ab02 100644 --- a/packages/hoppscotch-desktop/src-tauri/Cargo.lock +++ b/packages/hoppscotch-desktop/src-tauri/Cargo.lock @@ -3956,7 +3956,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relay" version = "0.1.1" -source = "git+https://github.com/CuriousCorrelation/relay.git#d258a2c1557b9da0715681a1f267a686eb4920bb" +source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4" dependencies = [ "bytes", "curl", @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "tauri-plugin-relay" version = "0.1.0" -source = "git+https://github.com/CuriousCorrelation/tauri-plugin-relay#4b96e40170c65189144299d896b7e97803f13cca" +source = "git+https://github.com/CuriousCorrelation/tauri-plugin-relay#124133dd126da3e0ed25ce578420c0ea2671e38e" dependencies = [ "relay", "serde", diff --git a/packages/hoppscotch-kernel/src/index.ts b/packages/hoppscotch-kernel/src/index.ts index 3439142f..e221b2f6 100644 --- a/packages/hoppscotch-kernel/src/index.ts +++ b/packages/hoppscotch-kernel/src/index.ts @@ -84,8 +84,9 @@ export type { export type { RelayRequest, RelayResponse, + PluginRequest, + PluginResponse, RelayResponseBody, - FormData, FormDataValue, RelayError, RelayV1, @@ -104,6 +105,7 @@ export { content, body, MediaType, + relayRequestToNativeAdapter } from '@relay/v/1' export type { diff --git a/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts b/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts index 85b88b56..029a9c52 100644 --- a/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts +++ b/packages/hoppscotch-kernel/src/relay/impl/desktop/v/1.ts @@ -7,13 +7,14 @@ import { type RelayResponse, type RelayError, body, + relayRequestToNativeAdapter, } from '@relay/v/1' import * as E from 'fp-ts/Either' import { execute, cancel, - type Request as PluginRequest, + type Request, type RequestResult } from '@hoppscotch/plugin-relay' @@ -135,25 +136,28 @@ export const implementation: VersionedAPI = { off: () => {} } - // SAFETY: Type assertion is safe because: - // 1. The capabilities system prevents requests with unsupported methods from reaching this point - // 2. Content types not supported by the plugin are filtered by capabilities - // 3. Authentication methods are validated through capabilities - // 4. The plugin's Request type is a subset of our Request type - const pluginRequest = { - id: request.id, - url: request.url, - method: request.method, - version: request.version, - headers: request.headers, - params: request.params, - content: request.content, - auth: request.auth, - security: request.security, - proxy: request.proxy, - } as PluginRequest + const responsePromise = relayRequestToNativeAdapter(request) + .then(request => { + // SAFETY: Type assertion is safe because: + // 1. The capabilities system prevents requests with unsupported methods from reaching this point + // 2. Content types not supported by the plugin are filtered by capabilities + // 3. Authentication methods are validated through capabilities + // 4. The plugin's Request type is a subset of our Request type + const pluginRequest = { + id: request.id, + url: request.url, + method: request.method, + version: request.version, + headers: request.headers, + params: request.params, + content: request.content, + auth: request.auth, + security: request.security, + proxy: request.proxy, + } - const responsePromise = execute(pluginRequest) + return execute(pluginRequest) + }) .then((result: RequestResult): E.Either => { if (result.kind === 'success') { const response: RelayResponse = { @@ -170,7 +174,7 @@ export const implementation: VersionedAPI = { end: result.response.meta.timing.end, }, size: result.response.meta.size, - } + } } return E.right(response) } diff --git a/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts b/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts index 0ea81e24..ce2417b5 100644 --- a/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts +++ b/packages/hoppscotch-kernel/src/relay/impl/web/v/1.ts @@ -1,6 +1,4 @@ import { - type FormData, - type FormDataValue, type RelayError, type RelayEventEmitter, type RelayRequest, diff --git a/packages/hoppscotch-kernel/src/relay/v/1.ts b/packages/hoppscotch-kernel/src/relay/v/1.ts index a9665599..b2376442 100644 --- a/packages/hoppscotch-kernel/src/relay/v/1.ts +++ b/packages/hoppscotch-kernel/src/relay/v/1.ts @@ -1,5 +1,9 @@ +import { Request, Response } from '@hoppscotch/plugin-relay' import type { VersionedAPI } from '@type/versioning' +export type PluginRequest = Request +export type PluginResponse = Response + import * as E from 'fp-ts/Either' export type Method = @@ -84,8 +88,6 @@ export type FormDataValue = | { kind: "text"; value: string } | { kind: "file"; filename: string; contentType: string; data: Uint8Array } -export type FormData = Map - export enum MediaType { TEXT_PLAIN = "text/plain", TEXT_HTML = "text/html", @@ -557,6 +559,68 @@ export const content = { }) } +// Helper function to convert standard `FormData` to `Map` +// This is mainly a crossplatform thing, once there's an equivalent and easy to impl `FormData` type for Rust, +// we can consider removing this. +const makeFormDataSerializable = async (formData: FormData): Promise> => { + const result = new Map() + // @ts-expect-error: `formData.entries` does exist but isn't visible, + // see `"lib": ["ESNext", "DOM"],` in `tsconfig.json` + for (const [key, value] of formData.entries()) { + if (value instanceof File || value instanceof Blob) { + const buffer = await value.arrayBuffer() + const fileEntry: FormDataValue = { + kind: "file", + filename: value instanceof File ? value.name : "unknown", + contentType: value.type || "application/octet-stream", + data: new Uint8Array(buffer) + } + + const existingValues = result.get(key) || [] + result.set(key, [...existingValues, fileEntry]) + } else { + const textEntry: FormDataValue = { + kind: "text", + value: value.toString() + } + + const existingValues = result.get(key) || [] + result.set(key, [...existingValues, textEntry]) + } + } + + return result +} + +// Helper function to adapt a relay request to work with the plugin +export const relayRequestToNativeAdapter = async (request: RelayRequest): Promise => { + const adaptedRequest = { ...request }; + + if (adaptedRequest.content?.kind === "multipart" && adaptedRequest.content.content instanceof FormData) { + const serializableFormData = await makeFormDataSerializable(adaptedRequest.content.content); + + // Replace with the converted form data + // SAFETY: Type assertion is necessary here because the plugin system expects + // types similar to Map instead of FormData. + // Then convert the `Map` to simpler nested object structure for better compatibility + // `Maps` it seems like are serialized differently across platforms and serialization libraries, + // while objects tend to maintain more consistent behavior by the sheer ubiquity of it. + const convertedContent: Record = {}; + + for (const [key, values] of serializableFormData.entries()) { + convertedContent[key] = Array.isArray(values) ? values : [values]; + } + + adaptedRequest.content = { + ...adaptedRequest.content, + // @ts-expect-error: This is intentional to work around SuperJSON serialization + content: convertedContent + }; + } + + return adaptedRequest as Request; +} + export const v1: VersionedAPI = { version: { major: 1, minor: 0, patch: 0 }, api: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 709d4e8b..163a1478 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1180,7 +1180,7 @@ importers: dependencies: '@hoppscotch/plugin-relay': specifier: github:CuriousCorrelation/tauri-plugin-relay - version: '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/4b96e40170c65189144299d896b7e97803f13cca' + version: '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/124133dd126da3e0ed25ce578420c0ea2671e38e' '@tauri-apps/api': specifier: 2.1.1 version: 2.1.1 @@ -1809,8 +1809,8 @@ packages: resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/1c2e8b19db7f1b6af6d00abb907f15cdc2017298} version: 0.1.0 - '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/4b96e40170c65189144299d896b7e97803f13cca': - resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/4b96e40170c65189144299d896b7e97803f13cca} + '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/124133dd126da3e0ed25ce578420c0ea2671e38e': + resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/124133dd126da3e0ed25ce578420c0ea2671e38e} version: 0.1.0 '@alloc/quick-lru@5.2.0': @@ -13236,7 +13236,7 @@ snapshots: dependencies: '@tauri-apps/api': 2.1.1 - '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/4b96e40170c65189144299d896b7e97803f13cca': + '@CuriousCorrelation/plugin-relay@https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/124133dd126da3e0ed25ce578420c0ea2671e38e': dependencies: '@tauri-apps/api': 2.1.1