feat(kernel): isolate type ser/de to platform (#4860)

This commit is contained in:
Shreyas 2025-03-10 22:45:51 +05:30 committed by GitHub
parent 3f37a055b1
commit dd3bf52711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 510 additions and 234 deletions

View file

@ -4020,7 +4020,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "relay" name = "relay"
version = "0.1.1" version = "0.1.1"
source = "git+https://github.com/CuriousCorrelation/relay.git#d258a2c1557b9da0715681a1f267a686eb4920bb" source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4"
dependencies = [ dependencies = [
"bytes", "bytes",
"curl", "curl",

View file

@ -5,7 +5,7 @@
"identifier": "io.hoppscotch.agent", "identifier": "io.hoppscotch.agent",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420", "devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },

View file

@ -43,7 +43,7 @@ export default defineConfig(async () => ({
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host: '127.0.0.1',
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",

View file

@ -4,10 +4,10 @@
class="flex flex-col items-left my-2 text-secondaryLight" class="flex flex-col items-left my-2 text-secondaryLight"
> >
<div class="text-secondaryLight"> <div class="text-secondaryLight">
<span v-if="O.isSome(extensionVersion)"> <span v-if="extensionVersion">
{{ {{
`${t("settings.extension_version")}: v${extensionVersion.value.major}.${ `${t("settings.extension_version")}: v${extensionVersion.major}.${
extensionVersion.value.minor extensionVersion.minor
}` }`
}} }}
</span> </span>
@ -40,16 +40,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import * as O from "fp-ts/Option"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { KernelInterceptorExtensionStore } from "~/platform/std/kernel-interceptors/extension/store" import { ExtensionKernelInterceptorService } from "~/platform/std/kernel-interceptors/extension"
import IconChrome from "~icons/brands/chrome" import IconChrome from "~icons/brands/chrome"
import IconFirefox from "~icons/brands/firefox" import IconFirefox from "~icons/brands/firefox"
const t = useI18n() const t = useI18n()
const store = useService(KernelInterceptorExtensionStore) const extensionInterceptorService = useService(
ExtensionKernelInterceptorService
)
const interceptorService = useService(KernelInterceptorService) const interceptorService = useService(KernelInterceptorService)
const isSelected = computed( const isSelected = computed(
@ -57,8 +58,10 @@ const isSelected = computed(
) )
const isNotAvailable = computed( const isNotAvailable = computed(
() => store.getExtensionStatus() !== "available" () => extensionInterceptorService.extensionStatus.value !== "available"
) )
const extensionVersion = computed(() => store.getExtensionVersion()) const extensionVersion = computed(
() => extensionInterceptorService.extensionVersion.value
)
</script> </script>

View file

@ -1,20 +1,12 @@
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { parseJSONAs } from "~/helpers/functional/json" import { parseJSONAs } from "~/helpers/functional/json"
import { ContentType, MediaType, content } from "@hoppscotch/kernel" import { ContentType, MediaType, content } from "@hoppscotch/kernel"
import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL" import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL"
type FormDataValue = {
kind: "file"
filename: string
contentType: string
data: Uint8Array
}
const Processors = { const Processors = {
json: { json: {
process: (body: string): E.Either<Error, ContentType> => process: (body: string): E.Either<Error, ContentType> =>
@ -25,78 +17,6 @@ const Processors = {
), ),
}, },
multipart: {
processFile: (entry: {
key: string
file: Blob
contentType?: string
}): TE.TaskEither<Error, { key: string; value: FormDataValue[] }> =>
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<Error, ContentType> =>
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<string, FormDataValue[]>(),
(acc, { key, value }) => {
acc.set(key, value)
return acc
}
)
)
)
),
TE.map((entries) => content.multipart(entries))
),
},
binary: { binary: {
process: (file: Blob): TE.TaskEither<Error, ContentType> => process: (file: Blob): TE.TaskEither<Error, ContentType> =>
pipe( pipe(
@ -169,10 +89,7 @@ export const transformContent = (
if (!(effectiveFinalBody instanceof FormData)) { if (!(effectiveFinalBody instanceof FormData)) {
return TE.right(O.none) return TE.right(O.none)
} }
return pipe( return TE.right(O.some(content.multipart(effectiveFinalBody)))
Processors.multipart.process(effectiveFinalBody),
TE.map(O.some)
)
case "application/octet-stream": case "application/octet-stream":
if (!(effectiveFinalBody instanceof Blob)) { if (!(effectiveFinalBody instanceof Blob)) {

View file

@ -22,11 +22,7 @@ export function createRESTNetworkRequestStream(
const req = cloneDeep(request) const req = cloneDeep(request)
console.info("[helpers/network]: req", req)
const execResult = RESTRequest.toRequest(req).then((kernelRequest) => { const execResult = RESTRequest.toRequest(req).then((kernelRequest) => {
console.info("[helpers/network]: kernelRequest", kernelRequest)
if (!kernelRequest) { if (!kernelRequest) {
response.next({ response.next({
type: "network_fail", type: "network_fail",

View file

@ -1,6 +1,6 @@
import { Service } from "dioc" import { Service } from "dioc"
import { markRaw } from "vue" import { markRaw } from "vue"
import { body } from "@hoppscotch/kernel" import { body, relayRequestToNativeAdapter } from "@hoppscotch/kernel"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import axios, { CancelTokenSource } from "axios" import axios, { CancelTokenSource } from "axios"
@ -132,9 +132,8 @@ export class AgentKernelInterceptorService
.join(";") .join(";")
} }
console.log("[AGENT]: effectiveRequest", effectiveRequest)
const [nonceB16, encryptedReq] = await this.store.encryptRequest( const [nonceB16, encryptedReq] = await this.store.encryptRequest(
effectiveRequest, await relayRequestToNativeAdapter(effectiveRequest),
reqID reqID
) )

View file

@ -4,7 +4,7 @@ import * as E from "fp-ts/Either"
import axios from "axios" import axios from "axios"
import superjson from "superjson" import superjson from "superjson"
import { Store } from "~/kernel/store" 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 { x25519 } from "@noble/curves/ed25519"
import { base16 } from "@scure/base" import { base16 } from "@scure/base"
import { import {
@ -156,8 +156,8 @@ export class KernelInterceptorAgentStore extends Service {
} }
public completeRequest( public completeRequest(
request: Omit<RelayRequest, "proxy" | "security"> request: Omit<PluginRequest, "proxy" | "security">
): RelayRequest { ): PluginRequest {
const host = new URL(request.url).host const host = new URL(request.url).host
const settings = this.getMergedSettings(host) const settings = this.getMergedSettings(host)
const effective = convertDomainSetting(settings) const effective = convertDomainSetting(settings)
@ -258,7 +258,7 @@ export class KernelInterceptorAgentStore extends Service {
} }
public async encryptRequest( public async encryptRequest(
request: RelayRequest, request: PluginRequest,
reqID: number reqID: number
): Promise<[string, ArrayBuffer]> { ): Promise<[string, ArrayBuffer]> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -291,7 +291,7 @@ export class KernelInterceptorAgentStore extends Service {
public async decryptResponse( public async decryptResponse(
nonceB16: string, nonceB16: string,
encryptedResponse: ArrayBuffer encryptedResponse: ArrayBuffer
): Promise<RelayResponse> { ): Promise<PluginResponse> {
const nonce = base16.decode(nonceB16.toUpperCase()) const nonce = base16.decode(nonceB16.toUpperCase())
const sharedSecretKeyBytes = base16.decode( const sharedSecretKeyBytes = base16.decode(
this.sharedSecretB16.value!.toUpperCase() this.sharedSecretB16.value!.toUpperCase()

View file

@ -1,20 +1,56 @@
import { computed, markRaw } from "vue" import { computed, markRaw, ref } from "vue"
import { Service } from "dioc" import { Service } from "dioc"
import type { RelayRequest, RelayResponse } from "@hoppscotch/kernel" import type { RelayRequest, RelayResponse } from "@hoppscotch/kernel"
import { body } from "@hoppscotch/kernel" import { body } from "@hoppscotch/kernel"
import SettingsExtension from "~/components/settings/Extension.vue" import SettingsExtension from "~/components/settings/Extension.vue"
import SettingsExtensionSubtitle from "~/components/settings/ExtensionSubtitle.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 * as E from "fp-ts/Either"
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import { until } from "@vueuse/core" import { until } from "@vueuse/core"
import { preProcessRelayRequest } from "~/helpers/functional/preprocess" import { preProcessRelayRequest } from "~/helpers/functional/preprocess"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent" import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import type {
KernelInterceptor,
ExecutionResult,
KernelInterceptorError,
} from "~/services/kernel-interceptor.service"
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
},
})
}
export type ExtensionStatus = "available" | "unknown-origin" | "waiting"
export const cancelRunningExtensionRequest = async () => { export const cancelRunningExtensionRequest = async () => {
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest()
@ -25,7 +61,8 @@ export class ExtensionKernelInterceptorService
implements KernelInterceptor implements KernelInterceptor
{ {
public static readonly ID = "KERNEL_EXTENSION_INTERCEPTOR_SERVICE" public static readonly ID = "KERNEL_EXTENSION_INTERCEPTOR_SERVICE"
private readonly store = this.bind(KernelInterceptorExtensionStore)
private _extensionStatus = ref<ExtensionStatus>("waiting")
public readonly id = "extension" public readonly id = "extension"
public readonly name = (t: ReturnType<typeof getI18n>) => { public readonly name = (t: ReturnType<typeof getI18n>) => {
@ -34,7 +71,7 @@ export class ExtensionKernelInterceptorService
if (this.extensionStatus.value === "available" && version) { if (this.extensionStatus.value === "available" && version) {
return `${t("settings.extensions")}: v${version.major}.${version.minor}` 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 } public readonly selectable = { type: "selectable" as const }
@ -71,6 +108,15 @@ export class ExtensionKernelInterceptorService
advanced: new Set(["localaccess"]), advanced: new Set(["localaccess"]),
} as const } 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({ public readonly settingsEntry = markRaw({
title: (t: ReturnType<typeof getI18n>) => t("settings.extensions"), title: (t: ReturnType<typeof getI18n>) => t("settings.extensions"),
component: SettingsExtension, component: SettingsExtension,
@ -78,14 +124,6 @@ export class ExtensionKernelInterceptorService
public readonly subtitle = markRaw(SettingsExtensionSubtitle) 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. * Whether the extension is installed in Chrome or not.
*/ */
@ -100,10 +138,80 @@ export class ExtensionKernelInterceptorService
() => this.extensionStatus.value === "available" && browserIsFirefox() () => this.extensionStatus.value === "available" && browserIsFirefox()
) )
override async onServiceInit(): Promise<void> {
this.setupExtensionStatusListener()
}
private setupExtensionStatusListener(): void {
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)
)
// 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( private async executeExtensionRequest(
request: RelayRequest request: RelayRequest
): Promise<E.Either<KernelInterceptorError, RelayResponse>> { ): Promise<E.Either<KernelInterceptorError, RelayResponse>> {
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__) { if (!window.__POSTWOMAN_EXTENSION_HOOK__) {
return E.left({ return E.left({
@ -119,12 +227,57 @@ export class ExtensionKernelInterceptorService
} }
try { 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 = const extensionResponse =
await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({ await window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({
url: request.url, url: request.url,
method: request.method, method: request.method,
headers: request.headers, headers: request.headers,
data: request.content?.content, data: requestData,
wantsBinary: true, wantsBinary: true,
}) })
@ -168,6 +321,28 @@ export class ExtensionKernelInterceptorService
public execute( public execute(
request: RelayRequest request: RelayRequest
): ExecutionResult<KernelInterceptorError> { ): ExecutionResult<KernelInterceptorError> {
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<typeof getI18n>) =>
t("error.extension.heading"),
description: (t: ReturnType<typeof getI18n>) =>
t("error.extension.description"),
},
error: {
kind: "extension",
message: "Extension not available",
},
})
),
}
}
const extensionExecution = { const extensionExecution = {
cancel: async () => { cancel: async () => {
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest() window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest()

View file

@ -83,7 +83,10 @@ export class KernelInterceptorExtensionStore extends Service {
} }
await this.loadSettings() await this.loadSettings()
this.setupExtensionStatusListener()
if (!this.tryDetectExtension()) {
this.setupExtensionStatusListener()
}
Store.watch(STORE_NAMESPACE, SETTINGS_KEY).on( Store.watch(STORE_NAMESPACE, SETTINGS_KEY).on(
"change", "change",
@ -123,6 +126,10 @@ export class KernelInterceptorExtensionStore extends Service {
this.updateSettings({ status }) this.updateSettings({ status })
) )
if (this.tryDetectExtension()) {
return
}
/** /**
* Keeping identifying extension backward compatible * Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately, * 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 * 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/ */
this.extensionPollIntervalId.value = setInterval(() => { this.extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") { if (this.tryDetectExtension() && this.extensionPollIntervalId.value) {
if (this.extensionPollIntervalId.value) clearInterval(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"
}
} }
}, 2000) }, 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<void> { private async loadSettings(): Promise<void> {
const loadResult = await Store.get<StoredData>( const loadResult = await Store.get<StoredData>(
STORE_NAMESPACE, STORE_NAMESPACE,

View file

@ -83,11 +83,6 @@ export class NativeKernelInterceptorService
.join(";") .join(";")
} }
console.info(
"[platform/std/kernel-interceptor/native/index]: effectiveRequest",
effectiveRequest
)
const relayExecution = Relay.execute(effectiveRequest) const relayExecution = Relay.execute(effectiveRequest)
const response = pipe(relayExecution.response, (promise) => const response = pipe(relayExecution.response, (promise) =>

View file

@ -1,6 +1,5 @@
import { markRaw } from "vue" import { markRaw } from "vue"
import type { import type {
FormDataValue,
RelayRequest, RelayRequest,
ContentType, ContentType,
Method, Method,
@ -17,10 +16,14 @@ import type {
KernelInterceptorError, KernelInterceptorError,
} from "~/services/kernel-interceptor.service" } from "~/services/kernel-interceptor.service"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import { v4 } from "uuid" import { v4 } from "uuid"
import { preProcessRelayRequest } from "~/helpers/functional/preprocess" import { preProcessRelayRequest } from "~/helpers/functional/preprocess"
import { parseBytesToJSON } from "~/helpers/functional/json"
import { decodeB64StringToArrayBuffer } from "~/helpers/utils/b64"
type ProxyRequest = { type ProxyRequest = {
url: string url: string
@ -83,32 +86,56 @@ export class ProxyKernelInterceptorService
request: RelayRequest, request: RelayRequest,
accessToken: string accessToken: string
): ProxyRequest { ): ProxyRequest {
let wantsBinary = false // NOTE: This should be conditional but for now setting it to true for backwards compat,
let requestData = "" // 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) {
if (request.content.kind === "text" || request.content.kind === "xml") { switch (request.content.kind) {
requestData = request.content.content case "json":
} else if (request.content.kind === "json") { requestData =
requestData = JSON.stringify(request.content.content) typeof request.content.content === "string"
} else if ( ? request.content.content
request.content.kind === "multipart" || : JSON.stringify(request.content.content)
request.content.kind === "form" break
) {
wantsBinary = true case "binary":
const formData = new FormData() if (
request.content.content.forEach( request.content.content instanceof Blob ||
(values: FormDataValue[], key: string) => { request.content.content instanceof File
values.forEach((value: FormDataValue) => { ) {
if (value.kind === "text") { requestData = request.content.content
formData.append(key, value.value) } else if (typeof request.content.content === "string") {
} else { // This is rather rare but just in case
const blob = new Blob([value.data], { type: value.contentType }) try {
formData.append(key, blob, value.filename) 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
requestData = formData.toString()
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 accessToken = settings.accessToken
const proxyUrl = settings.proxyUrl const proxyUrl = settings.proxyUrl
const proxyRequest = this.constructProxyRequest( const processedRequest = preProcessRelayRequest(request)
preProcessRelayRequest(request),
accessToken
)
const content: ContentType = { let content: ContentType
kind: "json", const multipartKey = `proxyRequestData-${v4()}`
content: proxyRequest,
mediaType: MediaType.APPLICATION_JSON, 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 = { const proxyRelayRequest: RelayRequest = {
@ -157,7 +220,7 @@ export class ProxyKernelInterceptorService
"content-type": content.mediaType, "content-type": content.mediaType,
...(content.kind === "multipart" ...(content.kind === "multipart"
? { ? {
"multipart-part-key": `proxyRequestData-${v4()}`, "multipart-part-key": multipartKey,
} }
: {}), : {}),
}, },
@ -236,19 +299,9 @@ export class ProxyKernelInterceptorService
return { humanMessage, error } return { humanMessage, error }
}), }),
E.chain((res) => { E.chain((res) => {
const proxyBody = const proxyResponse = parseBytesToJSON<ProxyResponse>(res.body.body)
res.body.mediaType === MediaType.TEXT_PLAIN
? new Uint8Array(res.body.body)
: null
// NOTE: This will become obsolete if we use native interceptor like error propagation. if (O.isNone(proxyResponse)) {
const proxyResponse = proxyBody
? (JSON.parse(
new TextDecoder().decode(proxyBody)
) as ProxyResponse)
: null
if (!proxyResponse?.success) {
return E.left({ return E.left({
humanMessage: { humanMessage: {
heading: (t) => t("error.network.heading"), 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({ return E.right({
...res, ...res,
status: parsedProxyResponse.status,
statusText: parsedProxyResponse.statusText,
headers: parsedProxyResponse.headers,
body: { body: {
body: proxyResponse.data, body: decodedData,
mediaType: mediaType:
proxyResponse.headers["content-type"] || parsedProxyResponse.headers["content-type"] ||
"application/octet-stream", "application/octet-stream",
}, },
}) })
@ -279,12 +378,13 @@ export class ProxyKernelInterceptorService
return E.right({ return E.right({
...res, ...res,
status: proxyResponse.status, status: parsedProxyResponse.status,
statusText: proxyResponse.statusText, statusText: parsedProxyResponse.statusText,
headers: proxyResponse.headers, headers: parsedProxyResponse.headers,
body: { body: {
body: new TextEncoder().encode(proxyResponse.data), body: parsedProxyResponse.data,
mediaType: "text/plain", mediaType:
parsedProxyResponse.headers["content-type"] || "text/plain",
}, },
}) })
}) })

View file

@ -201,7 +201,7 @@ impl<'a> ContentHandler<'a> {
for (key, values) in content { for (key, values) in content {
for value in values { for value in values {
match value { match value {
FormValue::Text(text) => { FormValue::Text { value: text } => {
tracing::debug!(key = %key, text_length = text.len(), "Adding form text field"); tracing::debug!(key = %key, text_length = text.len(), "Adding form text field");
form.part(key) form.part(key)
.contents(text.as_bytes()) .contents(text.as_bytes())

View file

@ -48,7 +48,10 @@ pub enum MediaType {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "kind", rename_all = "camelCase")] #[serde(tag = "kind", rename_all = "camelCase")]
pub enum FormValue { pub enum FormValue {
Text(String), #[serde(rename_all = "camelCase")]
Text {
value: String,
},
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
File { File {
filename: String, filename: String,

View file

@ -2812,7 +2812,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "relay" name = "relay"
version = "0.1.1" version = "0.1.1"
source = "git+https://github.com/CuriousCorrelation/relay.git#0a314ed5b71c74349d55f8213d57afbbe55abb87" source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4"
dependencies = [ dependencies = [
"bytes", "bytes",
"curl", "curl",

View file

@ -3956,7 +3956,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "relay" name = "relay"
version = "0.1.1" version = "0.1.1"
source = "git+https://github.com/CuriousCorrelation/relay.git#d258a2c1557b9da0715681a1f267a686eb4920bb" source = "git+https://github.com/CuriousCorrelation/relay.git#b744a64c0e40829a44a562e51f9d18b4696d2ba4"
dependencies = [ dependencies = [
"bytes", "bytes",
"curl", "curl",
@ -5081,7 +5081,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-relay" name = "tauri-plugin-relay"
version = "0.1.0" 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 = [ dependencies = [
"relay", "relay",
"serde", "serde",

View file

@ -84,8 +84,9 @@ export type {
export type { export type {
RelayRequest, RelayRequest,
RelayResponse, RelayResponse,
PluginRequest,
PluginResponse,
RelayResponseBody, RelayResponseBody,
FormData,
FormDataValue, FormDataValue,
RelayError, RelayError,
RelayV1, RelayV1,
@ -104,6 +105,7 @@ export {
content, content,
body, body,
MediaType, MediaType,
relayRequestToNativeAdapter
} from '@relay/v/1' } from '@relay/v/1'
export type { export type {

View file

@ -7,13 +7,14 @@ import {
type RelayResponse, type RelayResponse,
type RelayError, type RelayError,
body, body,
relayRequestToNativeAdapter,
} from '@relay/v/1' } from '@relay/v/1'
import * as E from 'fp-ts/Either' import * as E from 'fp-ts/Either'
import { import {
execute, execute,
cancel, cancel,
type Request as PluginRequest, type Request,
type RequestResult type RequestResult
} from '@hoppscotch/plugin-relay' } from '@hoppscotch/plugin-relay'
@ -135,25 +136,28 @@ export const implementation: VersionedAPI<RelayV1> = {
off: () => {} off: () => {}
} }
// SAFETY: Type assertion is safe because: const responsePromise = relayRequestToNativeAdapter(request)
// 1. The capabilities system prevents requests with unsupported methods from reaching this point .then(request => {
// 2. Content types not supported by the plugin are filtered by capabilities // SAFETY: Type assertion is safe because:
// 3. Authentication methods are validated through capabilities // 1. The capabilities system prevents requests with unsupported methods from reaching this point
// 4. The plugin's Request type is a subset of our Request type // 2. Content types not supported by the plugin are filtered by capabilities
const pluginRequest = { // 3. Authentication methods are validated through capabilities
id: request.id, // 4. The plugin's Request type is a subset of our Request type
url: request.url, const pluginRequest = {
method: request.method, id: request.id,
version: request.version, url: request.url,
headers: request.headers, method: request.method,
params: request.params, version: request.version,
content: request.content, headers: request.headers,
auth: request.auth, params: request.params,
security: request.security, content: request.content,
proxy: request.proxy, auth: request.auth,
} as PluginRequest security: request.security,
proxy: request.proxy,
}
const responsePromise = execute(pluginRequest) return execute(pluginRequest)
})
.then((result: RequestResult): E.Either<RelayError, RelayResponse> => { .then((result: RequestResult): E.Either<RelayError, RelayResponse> => {
if (result.kind === 'success') { if (result.kind === 'success') {
const response: RelayResponse = { const response: RelayResponse = {
@ -170,7 +174,7 @@ export const implementation: VersionedAPI<RelayV1> = {
end: result.response.meta.timing.end, end: result.response.meta.timing.end,
}, },
size: result.response.meta.size, size: result.response.meta.size,
} }
} }
return E.right(response) return E.right(response)
} }

View file

@ -1,6 +1,4 @@
import { import {
type FormData,
type FormDataValue,
type RelayError, type RelayError,
type RelayEventEmitter, type RelayEventEmitter,
type RelayRequest, type RelayRequest,

View file

@ -1,5 +1,9 @@
import { Request, Response } from '@hoppscotch/plugin-relay'
import type { VersionedAPI } from '@type/versioning' import type { VersionedAPI } from '@type/versioning'
export type PluginRequest = Request
export type PluginResponse = Response
import * as E from 'fp-ts/Either' import * as E from 'fp-ts/Either'
export type Method = export type Method =
@ -84,8 +88,6 @@ export type FormDataValue =
| { kind: "text"; value: string } | { kind: "text"; value: string }
| { kind: "file"; filename: string; contentType: string; data: Uint8Array } | { kind: "file"; filename: string; contentType: string; data: Uint8Array }
export type FormData = Map<string, FormDataValue[]>
export enum MediaType { export enum MediaType {
TEXT_PLAIN = "text/plain", TEXT_PLAIN = "text/plain",
TEXT_HTML = "text/html", TEXT_HTML = "text/html",
@ -557,6 +559,68 @@ export const content = {
}) })
} }
// Helper function to convert standard `FormData` to `Map<string, FormDataValue[]>`
// 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<Map<string, FormDataValue[]>> => {
const result = new Map<string, FormDataValue[]>()
// @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<Request> => {
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<string, FormDataValue[]> 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<string, FormDataValue[]> = {};
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<RelayV1> = { export const v1: VersionedAPI<RelayV1> = {
version: { major: 1, minor: 0, patch: 0 }, version: { major: 1, minor: 0, patch: 0 },
api: { api: {

View file

@ -1180,7 +1180,7 @@ importers:
dependencies: dependencies:
'@hoppscotch/plugin-relay': '@hoppscotch/plugin-relay':
specifier: github:CuriousCorrelation/tauri-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': '@tauri-apps/api':
specifier: 2.1.1 specifier: 2.1.1
version: 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} resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-appload/tar.gz/1c2e8b19db7f1b6af6d00abb907f15cdc2017298}
version: 0.1.0 version: 0.1.0
'@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':
resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/4b96e40170c65189144299d896b7e97803f13cca} resolution: {tarball: https://codeload.github.com/CuriousCorrelation/tauri-plugin-relay/tar.gz/124133dd126da3e0ed25ce578420c0ea2671e38e}
version: 0.1.0 version: 0.1.0
'@alloc/quick-lru@5.2.0': '@alloc/quick-lru@5.2.0':
@ -13236,7 +13236,7 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.1.1 '@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: dependencies:
'@tauri-apps/api': 2.1.1 '@tauri-apps/api': 2.1.1