feat: migrate ESLint to v9 across packages (#5773)

Co-authored-by: curiouscorrelation <curiouscorrelation@gmail.com>
This commit is contained in:
James George 2026-01-20 14:48:55 +05:30 committed by GitHub
parent 992579e285
commit 27b817f627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 3223 additions and 3263 deletions

View file

@ -0,0 +1,67 @@
import pluginVue from "eslint-plugin-vue"
import {
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript"
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"
export default defineConfigWithVueTs(
{
ignores: [
"**/*.d.ts",
"dist/**",
"node_modules/**",
"src-tauri/**",
],
},
pluginVue.configs["flat/recommended"],
vueTsConfigs.recommended,
eslintPluginPrettierRecommended,
{
files: ["**/*.ts", "**/*.js", "**/*.vue"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
},
rules: {
semi: [2, "never"],
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"no-undef": "off",
},
}
)

View file

@ -7,7 +7,14 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"lint": "eslint src",
"lint:ts": "vue-tsc --noEmit",
"lintfix": "eslint --fix src",
"prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
"do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint:ts",
"do-lintfix": "pnpm run lintfix"
},
"dependencies": {
"@hoppscotch/ui": "0.2.5",
@ -24,8 +31,16 @@
"@tauri-apps/cli": "2.9.3",
"@types/lodash-es": "4.17.12",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@vitejs/plugin-vue": "6.0.3",
"@vue/eslint-config-typescript": "14.6.0",
"autoprefixer": "10.4.23",
"cross-env": "10.1.0",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-vue": "10.6.2",
"globals": "16.5.0",
"postcss": "8.5.6",
"tailwindcss": "3.4.16",
"typescript": "5.9.3",

View file

@ -5,21 +5,33 @@
<template v-if="O.isSome(state().otp)">
<div class="flex-grow">
<p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please cancel the registration if you did not initiate this request.
The window will hide automatically once registration succeeds. If you minimize this window during registration,
you can access it again from the tray by selecting "Maximize Window".
An app is trying to register against the Hoppscotch Agent. If this was
intentional, copy the given code into the app to complete the
registration process. Please cancel the registration if you did not
initiate this request. The window will hide automatically once
registration succeeds. If you minimize this window during
registration, you can access it again from the tray by selecting
"Maximize Window".
</p>
<p
class="font-bold text-5xl tracking-wider text-center pt-10 text-white"
>{{ pipe(state().otp, O.getOrElse(() => "")) }}</p>
>
{{
pipe(
state().otp,
O.getOrElse(() => "")
)
}}
</p>
</div>
</template>
<template v-else>
<div class="flex-grow overflow-auto">
<HoppSmartTable :headings="tableHeadings" :list="state().registrations">
<template #registered_at="{ item }">{{ formatDate(String(item.registered_at)) }}</template>
<template #registered_at="{ item }">{{
formatDate(String(item.registered_at))
}}</template>
</HoppSmartTable>
</div>
</template>
@ -65,9 +77,9 @@ import {
HoppButtonSecondary,
HoppSmartTable,
} from "@hoppscotch/ui"
// @ts-ignore
// @ts-expect-error - Icon import has no types
import IconCopy from "~icons/lucide/copy"
// @ts-ignore
// @ts-expect-error - Icon import has no types
import IconCheck from "~icons/lucide/check"
import { useClipboard, refAutoReset } from "@vueuse/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
@ -183,7 +195,7 @@ onMounted(async () => {
if (otp) {
appState.value = { ...state(), otp: O.some(otp) }
} else {
updateRegistrations();
updateRegistrations()
}
})
)()
@ -204,12 +216,12 @@ onMounted(async () => {
getOtp,
TE.map((otp: string) => {
if (otp) {
appState.value = { ...state(), otp: O.some(otp) };
appState.value = { ...state(), otp: O.some(otp) }
} else {
updateRegistrations();
updateRegistrations()
}
})
)();
)()
}),
])
})

View file

@ -1,6 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
import { createApp } from "vue"
import App from "./App.vue"
import "./index.css"
import { plugin as HoppUI } from "@hoppscotch/ui"
@ -8,6 +8,4 @@ import "@hoppscotch/ui/themes.css"
import "@hoppscotch/ui/style.css"
createApp(App)
.use(HoppUI)
.mount('#app')
createApp(App).use(HoppUI).mount("#app")

View file

@ -3,17 +3,21 @@
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
<div v-if="otpCode">
<p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please hide the window if you did not initiate this request.
Do not hide this window until the verification code is entered. The window will hide automatically once done.
An app is trying to register against the Hoppscotch Agent. If this was
intentional, copy the given code into the app to complete the
registration process. Please hide the window if you did not initiate
this request. Do not hide this window until the verification code is
entered. The window will hide automatically once done.
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">
{{ otpCode }}
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">{{ otpCode }}</p>
</div>
<div v-else class="text-center pt-10">
<p class="tracking-wide">Waiting for registration requests...</p>
<p
class="text-sm text-gray-400 mt-2"
>You can hide this window and access it again from the tray icon.</p>
<p class="text-sm text-gray-400 mt-2">
You can hide this window and access it again from the tray icon.
</p>
</div>
<div class="border-t border-divider p-5 flex justify-between">
<HoppButtonSecondary
@ -35,10 +39,12 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, markRaw, onMounted } from "vue"
import { HoppButtonPrimary, HoppButtonSecondary } from "@hoppscotch/ui"
import { HoppButtonSecondary } from "@hoppscotch/ui"
// @ts-expect-error - Icon import has no types
import IconCopy from "~icons/lucide/copy"
// @ts-expect-error - Icon import has no types
import IconCheck from "~icons/lucide/check"
import { useClipboard, refAutoReset } from "@vueuse/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
@ -64,12 +70,12 @@ onMounted(async () => {
const currentWindow = getCurrentWindow()
currentWindow.setAlwaysOnTop(true)
const initialOtp = await invoke("get_otp", {})
const initialOtp = await invoke<string>("get_otp", {})
if (initialOtp) {
otpCode.value = initialOtp
}
await listen("registration-received", (event) => {
await listen<string>("registration-received", (event) => {
otpCode.value = event.payload
currentWindow.setFocus()
})

View file

@ -9,7 +9,9 @@
]"
:list="registrations"
>
<template #registered_at="{ item }">{{ formatDate(item.registered_at) }}</template>
<template #registered_at="{ item }">{{
formatDate(item.registered_at)
}}</template>
</HoppSmartTable>
</div>
<div class="border-t border-divider p-5 flex justify-between">
@ -18,17 +20,26 @@
</div>
</template>
<script setup>
import { ref, markRaw, onMounted } from "vue"
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { HoppButtonPrimary, HoppSmartTable } from "@hoppscotch/ui"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { orderBy } from "lodash-es"
const registrations = ref([])
interface Registration {
auth_key_hash: string
registered_at: string
}
function formatDate(date) {
interface ListRegistrationsResult {
registrations: Registration[]
}
const registrations = ref<Registration[]>([])
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
@ -38,7 +49,7 @@ function hideWindow() {
}
async function loadRegistrations() {
const result = await invoke("list_registrations", {})
const result = await invoke<ListRegistrationsResult>("list_registrations", {})
registrations.value = orderBy(result.registrations, "registered_at", "desc")
}

View file

@ -1,83 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
parserOptions: {
sourceType: "module",
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
extends: [
"@vue/typescript/recommended",
"plugin:vue/recommended",
"plugin:prettier/recommended",
],
ignorePatterns: [
"static/**/*",
"./helpers/backend/graphql.ts",
"**/*.d.ts",
"types/**/*",
],
plugins: ["vue", "prettier"],
// add your custom rules here
rules: {
semi: [2, "never"],
"import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2,
},
],
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/default": "off",
"no-undef": "off",
// localStorage block
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
// window.localStorage block
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
eqeqeq: 1,
"no-else-return": 1,
},
}

View file

@ -0,0 +1,96 @@
import pluginVue from "eslint-plugin-vue"
import {
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript"
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"
export default defineConfigWithVueTs(
{
ignores: [
"static/**",
"src/helpers/backend/graphql.ts",
"**/*.d.ts",
"types/**",
"dist/**",
"node_modules/**",
],
},
pluginVue.configs["flat/recommended"],
vueTsConfigs.recommended,
eslintPluginPrettierRecommended,
{
files: ["**/*.ts", "**/*.js", "**/*.vue"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
},
rules: {
semi: [2, "never"],
"import/named": "off",
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
useTabs: false,
tabWidth: 2,
},
],
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"import/default": "off",
"no-undef": "off",
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
eqeqeq: 1,
"no-else-return": 1,
},
}
)

View file

@ -8,10 +8,10 @@
"test:watch": "vitest",
"dev:vite": "vite",
"dev:gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml --watch dotenv_config_path=\"../../.env\"",
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lint": "eslint src",
"lint:ts": "vue-tsc --noEmit",
"prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
"lintfix": "eslint --fix src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lintfix": "eslint --fix src",
"preview": "vite preview",
"gql-codegen": "graphql-codegen --require dotenv/config --config gql-codegen.yml dotenv_config_path=\"../../.env\"",
"postinstall": "pnpm run gql-codegen",
@ -127,6 +127,8 @@
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@esbuild-plugins/node-modules-polyfill": "0.2.2",
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"@graphql-codegen/add": "6.0.0",
"@graphql-codegen/cli": "6.1.0",
"@graphql-codegen/typed-document-node": "6.1.5",
@ -153,15 +155,16 @@
"@typescript-eslint/parser": "8.50.0",
"@vitejs/plugin-vue": "6.0.3",
"@vue/compiler-sfc": "3.5.26",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/eslint-config-typescript": "14.6.0",
"@vue/runtime-core": "3.5.26",
"autoprefixer": "10.4.23",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint": "8.57.0",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-vue": "10.6.2",
"glob": "13.0.0",
"globals": "16.5.0",
"jsdom": "27.3.0",
"npm-run-all": "4.1.5",
"openapi-types": "12.1.3",

View file

@ -97,7 +97,7 @@ const fetchAccessTokens = async () => {
if (tokensListFetchErrored.value) {
tokensListFetchErrored.value = false
}
} catch (err) {
} catch (_err) {
toast.error(t("error.fetching_access_tokens_list"))
tokensListFetchErrored.value = true
} finally {
@ -136,7 +136,7 @@ const generateAccessToken = async ({
if (tokensListFetchErrored.value) {
tokensListFetchErrored.value = false
}
} catch (err) {
} catch (_err) {
toast.error(t("error.generate_access_token"))
showAccessTokensGenerateModal.value = false
} finally {
@ -175,7 +175,7 @@ const deleteAccessToken = async () => {
if (tokensListFetchErrored.value) {
tokensListFetchErrored.value = false
}
} catch (err) {
} catch (_err) {
toast.error(t("error.delete_access_token"))
} finally {
tokenDeleteActionLoading.value = false

View file

@ -60,7 +60,6 @@ watch(
doc: props.contentRight.content,
extensions: [jsonLanguage, baseTheme, basicSetup],
},
// @ts-expect-error attribute mismatch
parent: diffEditor.value,
highlightChanges: false,
})

View file

@ -58,7 +58,7 @@ const request = computed(() => {
return pathFolders.value[pathFolders.value.length - 1].requests[
requestIndex
]
} catch (e) {
} catch (_e) {
return null
}
})

View file

@ -64,7 +64,7 @@ const request = computed(() => {
return pathFolders.value[pathFolders.value.length - 1].requests[
requestIndex
]
} catch (e) {
} catch (_e) {
return null
}
})

View file

@ -253,7 +253,7 @@ const countPostmanScripts = (
if (collection?.item && Array.isArray(collection.item)) {
collection.item.forEach(countInItem)
}
} catch (e) {
} catch (_e) {
// Invalid JSON, skip
}
})
@ -337,7 +337,7 @@ const HoppAllCollectionImporter: ImporterOrExporter = {
exporter: "import_to_teams",
platform: "rest",
})
} catch (e) {
} catch (_e) {
showImportFailedError()
unsetCurrentImportSummary()
}

View file

@ -342,7 +342,7 @@ const fetchTeamCollection = async () => {
props.collection as TeamCollection
)
}
} catch (error) {
} catch (_error) {
fullCollectionData.value = teamCollToHoppRESTColl(
props.collection as TeamCollection
)

View file

@ -81,7 +81,7 @@ function formatJSON(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString || "{}")
return JSON.stringify(parsed, null, 2)
} catch (e) {
} catch (_e) {
return jsonString || ""
}
}
@ -94,7 +94,7 @@ function formatJSON(jsonString: string): string {
function parseFormData(formData: string): { key: string; value: string }[] {
try {
return typeof formData === "string" ? parseRawKeyValueEntries(formData) : []
} catch (e) {
} catch (_e) {
return []
}
}

View file

@ -212,7 +212,7 @@ function isJsonResponse(example: ResponseExample): boolean {
try {
JSON.parse(example.body || "")
return true
} catch (e) {
} catch (_e) {
return false
}
}
@ -226,7 +226,7 @@ function formatJSON(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString || "{}")
return JSON.stringify(parsed, null, 2)
} catch (e) {
} catch (_e) {
return jsonString || ""
}
}

View file

@ -174,7 +174,7 @@ const debouncedOnUpdateQueryState = debounce((update: ViewUpdate) => {
const { start, end } = def.loc!
return selectedPos >= start && selectedPos <= end
}) as OperationDefinitionNode) ?? null
} catch (error) {
} catch (_error) {
if (queryString.trim() === "") {
operationDefinitions.value = []
}
@ -193,7 +193,7 @@ onMounted(() => {
selectedOperation.value = ast.definitions[0] as OperationDefinitionNode
return
}
} catch (error) {}
} catch (_error) {}
})
const cmQueryEditor = useCodemirror(
@ -238,7 +238,7 @@ const prettifyQuery = () => {
})
)
prettifyQueryIcon.value = IconCheck
} catch (e) {
} catch (_e) {
toast.error(`${t("error.gql_prettify_invalid_query")}`)
prettifyQueryIcon.value = IconInfo
}

View file

@ -77,14 +77,14 @@ import { platform } from "~/platform"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { GQLTabService } from "~/services/tab/graphql"
const VALID_GQL_OPERATIONS = [
const _VALID_GQL_OPERATIONS = [
"query",
"headers",
"variables",
"authorization",
] as const
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
export type GQLOptionTabs = (typeof _VALID_GQL_OPERATIONS)[number]
const interceptorService = useService(KernelInterceptorService)

View file

@ -600,7 +600,7 @@ const saveRequest = async () => {
})
toast.success(`${t("request.saved")}`)
} catch (e) {
} catch (_e) {
tab.value.document.saveContext = undefined
saveRequest()
}

View file

@ -110,7 +110,7 @@ import { defineActionHandler } from "~/helpers/actions"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments"
const VALID_OPTION_TABS = [
const _VALID_OPTION_TABS = [
"params",
"bodyParams",
"headers",
@ -120,7 +120,7 @@ const VALID_OPTION_TABS = [
"requestVariables",
] as const
export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number]
export type RESTOptionTabs = (typeof _VALID_OPTION_TABS)[number]
const t = useI18n()

View file

@ -172,7 +172,7 @@ function getCurrentPageCategory(): "graphql" | "rest" | "other" {
return "rest"
}
return "other"
} catch (e) {
} catch (_e) {
return "other"
}
}

View file

@ -168,7 +168,7 @@ const retryWithProxy = async () => {
} else {
toast.error(t("import.failed"))
}
} catch (error) {
} catch (_error) {
toast.error(t("import.failed"))
} finally {
isFetchingUrl.value = false

View file

@ -73,7 +73,7 @@ async function register() {
try {
await agentService.initiateRegistration()
registrationStatus.value = "otp_required"
} catch (error) {
} catch (_error) {
toast.error("Failed to initiate registration. Please try again.")
registrationStatus.value = "initial"
}
@ -85,7 +85,7 @@ async function verifyOTP(otp: string) {
await agentService.verifyRegistration(otp)
toast.success("Registration successful!")
hideModal()
} catch (error) {
} catch (_error) {
toast.error("Failed to verify OTP. Please try again.")
registrationStatus.value = "otp_required"
}

View file

@ -294,7 +294,7 @@ const copyToClipboardHandler = async (text: string) => {
setTimeout(() => {
copyIcon.value = IconCopy
}, 1000)
} catch (error) {
} catch (_error) {
toast.error(t("error.copy_failed"))
}
}

View file

@ -354,7 +354,7 @@ const copyToClipboardHandler = async (text: string) => {
setTimeout(() => {
copyIcon.value = IconCopy
}, 1000)
} catch (error) {
} catch (_error) {
toast.error(t("error.copy_failed"))
}
}

View file

@ -668,7 +668,7 @@ async function updateMaskedAuthKey() {
try {
const registration = await store.fetchRegistrationInfo()
maskedAuthKey.value = registration.auth_key_hash
} catch (e) {
} catch (_e) {
toast.error(t("settings.agent_registration_fetch_failed"))
}
}

View file

@ -126,7 +126,7 @@ const register = async () => {
await updateMaskedAuthKey()
toast.success(t("settings.agent_registration_successful"))
store.registrationOTP.value = ""
} catch (e) {
} catch (_e) {
} finally {
store.isRegistering.value = false
}
@ -146,7 +146,7 @@ const updateMaskedAuthKey = async () => {
try {
const registration = await store.fetchRegistrationInfo()
store.maskedAuthKey.value = registration.auth_key_hash
} catch (e) {}
} catch (_e) {}
}
onMounted(async () => {

View file

@ -13,7 +13,7 @@ export function initializeApp() {
platform.analytics?.initAnalytics()
initialized = true
} catch (e) {
} catch (_e) {
// initializeApp throws exception if we reinitialize
initialized = true
}

View file

@ -45,7 +45,7 @@ type TypeFromPrimitiveArray<P extends JSPrimitive | undefined> =
: P extends "symbol"
? symbol[]
: P extends "function"
? Function[] // eslint-disable-line @typescript-eslint/ban-types
? Function[]
: unknown[]
// The ban-types silence is because in this case,

View file

@ -24,7 +24,7 @@ export type TypeFromPrimitive<P extends JSPrimitive | undefined> =
: P extends "symbol"
? symbol
: P extends "function"
? Function // eslint-disable-line @typescript-eslint/ban-types
? Function
: unknown
// The ban-types silence is because in this case,

View file

@ -206,7 +206,7 @@ const parseOpenAPIV3Responses = (
try {
stringifiedBody = JSON.stringify(body ?? "")
// the parsing will fail for a circular response schema
} catch (e) {
} catch (_e) {
// eat five star, do nothing
}
@ -435,7 +435,7 @@ const parseOpenAPIV3Body = (
? sampleBody
: JSON.stringify(sampleBody, null, 2),
}
} catch (e) {
} catch (_e) {
// If we can't generate a sample, check for examples
if (media.example !== undefined) {
return {
@ -1152,7 +1152,7 @@ export const hoppOpenAPIImporter = (fileContents: string[]) =>
try {
const validatedDoc = await dereferenceDocs(docObj)
resultDoc.push(validatedDoc)
} catch (error) {
} catch (_error) {
// Check if the document has unresolved references
if (hasUnresolvedRefs(docObj)) {
console.warn(

View file

@ -12,12 +12,11 @@ import * as E from "fp-ts/Either"
const validateDocs = async (docs: any) => {
try {
const res = await SwaggerParser.validate(docs, {
// @ts-expect-error - this is a valid option, but seems like the types are not updated
continueOnError: true,
})
return E.right(res)
} catch (error) {
} catch (_error) {
return E.left("COULD_NOT_VALIDATE" as const)
}
}
@ -27,7 +26,7 @@ const dereferenceDocs = async (docs: any) => {
const res = await SwaggerParser.dereference(docs)
return E.right(res)
} catch (error) {
} catch (_error) {
return E.left("COULD_NOT_DEREFERENCE" as const)
}
}

View file

@ -61,7 +61,7 @@ export const isValidUser = async (): Promise<ValidUserResponse> => {
// For platforms without token verification capability
return { valid: true, error: "" }
} catch (error) {
} catch (_error) {
// Handle errors from token verification
return attemptTokenRefresh()
}

View file

@ -97,7 +97,7 @@ export default function jsonParse(
const ast = parseObj()
expect("EOF")
return ast
} catch (e) {
} catch (_e) {
// Try parsing expecting a root array
const ast = parseArr()
expect("EOF")

View file

@ -120,7 +120,7 @@ export default function jsonParse(
const ast = parseObj()
expect("EOF")
return ast
} catch (e) {
} catch (_e) {
pendingComments = [] // Reset pending comments
const ast = parseArr()
expect("EOF")

View file

@ -22,7 +22,7 @@ export function isValidJSONResponse(contents: string | ArrayBuffer): boolean {
try {
JSON.parse(resolvedStr)
return true
} catch (e) {
} catch (_e) {
return false
}
}

View file

@ -71,7 +71,7 @@ export function createRESTNetworkRequestStream(
try {
const result = await execResult
if (result) await result.cancel()
} catch (error) {
} catch (_error) {
// Ignore cancel errors - request may have already completed
// This is expected behavior and not an actual error
}

View file

@ -94,7 +94,7 @@ export function getJSONOutlineAtPos(
}
return path
} catch (e: any) {
} catch (_e: any) {
return null
}
}

View file

@ -195,7 +195,7 @@ export class MQTTConnection {
message,
},
})
} catch (e) {
} catch (_e) {
this.addEvent({
time: Date.now(),
type: "ERROR",
@ -216,7 +216,7 @@ export class MQTTConnection {
onFailure: this.usubFailure.bind(this, topic.name),
qos: topic.qos,
})
} catch (e) {
} catch (_e) {
this.subscribing$.next(false)
this.addEvent({
time: Date.now(),

View file

@ -365,7 +365,7 @@ function makeVisitors(server, query, file, messages) {
infer.findRefs(cur.ast, cur.scope, name, scope, searchRef(cur))
}
}
} catch (e) {}
} catch (_e) {}
return hasRef
}

View file

@ -4,7 +4,7 @@ export function parseUrlAndPath(value) {
const url = new URL(value)
result.url = url.origin
result.path = url.pathname
} catch (e) {
} catch (_e) {
const uriRegex = value.match(
/^((http[s]?:\/\/)?(<<[^/]+>>)?[^/]*|)(\/?.*)$/
)

View file

@ -174,7 +174,7 @@ export function loadMockServers(skip?: number, take?: number) {
}
)
)()
} catch (error) {
} catch (_error) {
// Fallback to user mock servers if workspace service is not available
return pipe(
getMyMockServers(skip, take),

View file

@ -289,8 +289,8 @@ export function applySetting<K extends keyof SettingsDef>(
) {
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
// @ts-expect-error TS is not able to understand the type semantics here
payload: {
settingKey,
value,
},

View file

@ -328,7 +328,7 @@ export class AgentInterceptorService extends Service implements Interceptor {
try {
const proxyInfo = JSON.parse(persistedProxyInfo)
this.proxyInfo.value = proxyInfo
} catch (e) {}
} catch (_e) {}
}
// Load SSL Validation
@ -557,7 +557,7 @@ export class AgentInterceptorService extends Service implements Interceptor {
try {
await this.performHandshake()
this.isAgentRunning.value = true
} catch (error) {
} catch (_error) {
this.isAgentRunning.value = false
}
}

View file

@ -6,7 +6,7 @@ import { useSetting } from "~/composables/settings"
const isEncoded = (value: string) => {
try {
return value !== decodeURIComponent(value)
} catch (e) {
} catch (_e) {
return false // in case of malformed URI sequence
}
}
@ -42,7 +42,7 @@ export const preProcessRequest = (
// decode the URL to prevent double encoding
reqClone.url = decodeURIComponent(url.toString())
} catch (e) {
} 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 :)
}

View file

@ -32,7 +32,7 @@ describe("URLMenuService", () => {
try {
new URL(url)
return true
} catch (error) {
} catch (_error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)

View file

@ -22,7 +22,7 @@ function isValidURL(url: string) {
// this will fail for endpoints like "localhost:3000", ie without a protocol
new URL(url)
return true
} catch (error) {
} catch (_error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)

View file

@ -185,7 +185,7 @@ const initAuthCodeOauthFlow = async ({
try {
url = new URL(authEndpoint)
} catch (e) {
} catch (_e) {
return E.left("INVALID_AUTH_ENDPOINT")
}

View file

@ -354,7 +354,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted LOCAL_STATE:`, loadResult)
}
@ -386,7 +386,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted SETTINGS:`, loadResult)
}
@ -418,7 +418,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted REST_HISTORY:`, restLoadResult)
}
@ -450,7 +450,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted GQL_HISTORY:`, gqlLoadResult)
}
@ -486,7 +486,7 @@ export class PersistenceService extends Service {
setRESTCollections(data)
}
}
} catch (e) {
} catch (_e) {
console.error(
`Failed parsing persisted REST_COLLECTIONS:`,
restLoadResult
@ -523,7 +523,7 @@ export class PersistenceService extends Service {
setGraphqlCollections(data)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted GQL_COLLECTIONS:`, gqlLoadResult)
}
@ -569,7 +569,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted ENVIRONMENTS:`, loadResult)
}
@ -607,7 +607,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted SECRET_ENVIRONMENTS:`, loadResult)
}
@ -653,7 +653,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(
`Failed parsing persisted CURRENT_ENVIRONMENT_VALUE:`,
loadResult
@ -699,7 +699,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted SELECTED_ENV:`, loadResult)
}
@ -735,7 +735,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted CURRENT_SORT_VALUES:`, loadResult)
}
@ -780,7 +780,7 @@ export class PersistenceService extends Service {
}
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted WEBSOCKET:`, loadResult)
}
@ -817,7 +817,7 @@ export class PersistenceService extends Service {
}
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted SOCKETIO:`, loadResult)
}
@ -847,7 +847,7 @@ export class PersistenceService extends Service {
}
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted SSE:`, loadResult)
}
@ -877,7 +877,7 @@ export class PersistenceService extends Service {
}
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted MQTT:`, loadResult)
}
@ -908,7 +908,7 @@ export class PersistenceService extends Service {
)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted GLOBAL_ENV:`, loadResult)
}
@ -955,7 +955,7 @@ export class PersistenceService extends Service {
this.restTabService.loadTabsFromPersistedState(loadResult.right)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted REST_TABS:`, loadResult)
}
@ -998,7 +998,7 @@ export class PersistenceService extends Service {
this.gqlTabService.loadTabsFromPersistedState(loadResult.right)
}
}
} catch (e) {
} catch (_e) {
console.error(`Failed parsing persisted GQL_TABS:`, loadResult)
}

View file

@ -119,7 +119,7 @@ export class CollectionsSpotlightSearcherService
return "rest"
}
return "other"
} catch (e) {
} catch (_e) {
return "other"
}
}

View file

@ -54,7 +54,7 @@ export class TeamsSpotlightSearcherService
return "rest"
}
return "other"
} catch (e) {
} catch (_e) {
return "other"
}
}

View file

@ -194,7 +194,7 @@ export class TestRunnerService extends Service {
if (options.delay && options.delay > 0) {
try {
await delay(options.delay)
} catch (error) {
} catch (_error) {
if (options.stopRef?.value) {
tab.value.document.status = "stopped"
throw new Error("Test execution stopped")

View file

@ -1,67 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
root: true,
env: {
browser: true,
node: true,
jest: true,
},
parserOptions: {
sourceType: "module",
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
extends: [
"@vue/typescript/recommended",
"plugin:vue/recommended",
"plugin:prettier/recommended",
],
ignorePatterns: [
"static/**/*",
"./helpers/backend/graphql.ts",
"**/*.d.ts",
"types/**/*",
],
plugins: ["vue", "prettier"],
// add your custom rules here
rules: {
semi: [2, "never"],
"import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/default": "off",
"no-undef": "off",
// localStorage block
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
},
],
// window.localStorage block
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
},
],
},
}

View file

@ -0,0 +1,79 @@
import pluginVue from "eslint-plugin-vue"
import {
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript"
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"
export default defineConfigWithVueTs(
{
ignores: [
"static/**",
"src/helpers/backend/graphql.ts",
"**/*.d.ts",
"types/**",
"dist/**",
"node_modules/**",
],
},
pluginVue.configs["flat/recommended"],
vueTsConfigs.recommended,
eslintPluginPrettierRecommended,
{
files: ["**/*.ts", "**/*.js", "**/*.vue"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
},
rules: {
semi: [2, "never"],
"import/named": "off",
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"import/default": "off",
"no-undef": "off",
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
},
}
)

View file

@ -8,10 +8,13 @@
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lint": "eslint src",
"lint:ts": "vue-tsc --noEmit",
"lintfix": "eslint --fix src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lintfix": "eslint --fix src",
"prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
"do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint:ts",
"do-lintfix": "pnpm run lintfix",
"prepare-web": "(cd ../hoppscotch-selfhost-web && pnpm install && pnpm generate) && (cd crates/webapp-bundler && cargo build --release && cd target/release && ./webapp-bundler --input ../../../../../hoppscotch-selfhost-web/dist --output ../../../../bundle.zip --manifest ../../../../manifest.json)",
"dev:full": "pnpm tauri dev",
"build:full": "pnpm tauri build",
@ -20,7 +23,7 @@
},
"dependencies": {
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-rounded": "5.2.24",
"@fontsource-variable/material-symbols-rounded": "5.2.30",
"@fontsource-variable/roboto-mono": "5.2.8",
"@hoppscotch/common": "workspace:^",
"@hoppscotch/kernel": "workspace:^",
@ -29,36 +32,38 @@
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-fs": "2.0.2",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.1",
"@tauri-apps/plugin-store": "2.2.0",
"@tauri-apps/plugin-updater": "2.5.1",
"@vueuse/core": "13.7.0",
"@tauri-apps/plugin-shell": "2.3.3",
"@tauri-apps/plugin-store": "2.4.1",
"@tauri-apps/plugin-updater": "2.9.0",
"fp-ts": "2.16.11",
"rxjs": "7.8.2",
"vue": "3.5.22",
"vue-router": "4.6.3",
"vue": "3.5.26",
"vue-router": "4.6.4",
"vue-tippy": "6.7.1",
"zod": "3.25.32"
},
"devDependencies": {
"@iconify-json/lucide": "1.2.68",
"@rushstack/eslint-patch": "1.14.0",
"@tauri-apps/cli": "^2",
"@typescript-eslint/eslint-plugin": "8.44.1",
"@typescript-eslint/parser": "8.44.1",
"@vitejs/plugin-vue": "5.1.4",
"@vue/eslint-config-typescript": "13.0.0",
"autoprefixer": "10.4.21",
"eslint": "8.57.0",
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"@iconify-json/lucide": "1.2.81",
"@rushstack/eslint-patch": "1.15.0",
"@tauri-apps/cli": "2.9.3",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@vitejs/plugin-vue": "6.0.3",
"@vue/eslint-config-typescript": "14.6.0",
"autoprefixer": "10.4.23",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-vue": "10.5.1",
"eslint-plugin-vue": "10.6.2",
"globals": "16.5.0",
"postcss": "8.5.6",
"sass": "1.93.2",
"sass": "1.97.0",
"tailwindcss": "3.4.16",
"typescript": "5.9.3",
"unplugin-icons": "22.2.0",
"unplugin-vue-components": "29.0.0",
"vite": "6.3.5",
"unplugin-icons": "22.5.0",
"unplugin-vue-components": "30.0.0",
"vite": "7.3.0",
"vue-tsc": "2.2.0"
}
}

View file

@ -1,8 +1,11 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */

View file

@ -1,42 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
jest: true,
browser: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
requireConfigFile: false,
ecmaVersion: 2021,
},
plugins: ["prettier"],
extends: [
"prettier/prettier",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
semi: [2, "never"],
"prettier/prettier": ["warn", { semi: false, trailingComma: "es5" }],
"import/no-named-as-default": "off",
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
}

View file

@ -0,0 +1,70 @@
const { FlatCompat } = require("@eslint/eslintrc")
const js = require("@eslint/js")
const tsParser = require("@typescript-eslint/parser")
const typescriptEslintPlugin = require("@typescript-eslint/eslint-plugin")
const prettierPlugin = require("eslint-plugin-prettier")
const globals = require("globals")
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
})
module.exports = [
{
ignores: [
"dist/**",
"node_modules/**",
"**/*.d.ts",
"eslint.config.cjs",
".prettierrc.cjs",
"src/bootstrap-code/**",
],
},
...compat.extends(
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
),
{
files: ["**/*.ts", "**/*.js"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
parser: tsParser,
sourceType: "module",
ecmaVersion: 2021,
globals: {
...globals.node,
...globals.jest,
...globals.browser,
},
},
plugins: {
"@typescript-eslint": typescriptEslintPlugin,
prettier: prettierPlugin,
},
rules: {
semi: [2, "never"],
"prettier/prettier": ["warn", { semi: false, trailingComma: "es5" }],
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
]

View file

@ -27,8 +27,8 @@
"node": ">=22"
},
"scripts": {
"lint": "eslint --ext .ts,.js --ignore-path .gitignore .",
"lintfix": "eslint --fix --ext .ts,.js --ignore-path .gitignore .",
"lint": "eslint .",
"lintfix": "eslint --fix .",
"test": "vitest run",
"build": "vite build && tsc --emitDeclarationOnly",
"clean": "pnpm tsc --build --clean",
@ -67,7 +67,10 @@
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"eslint": "8.57.0",
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"eslint": "9.39.2",
"globals": "16.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.4",
"io-ts": "2.2.22",

View file

@ -0,0 +1,52 @@
import js from "@eslint/js"
import tsParser from "@typescript-eslint/parser"
import typescriptEslintPlugin from "@typescript-eslint/eslint-plugin"
import prettierPlugin from "eslint-plugin-prettier"
import globals from "globals"
export default [
{
ignores: [
"dist/**",
"node_modules/**",
"**/*.d.ts",
"vite.config.ts",
],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsParser,
sourceType: "module",
ecmaVersion: 2022,
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"@typescript-eslint": typescriptEslintPlugin,
prettier: prettierPlugin,
},
rules: {
...typescriptEslintPlugin.configs.recommended.rules,
"prettier/prettier": "warn",
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
]

View file

@ -15,7 +15,11 @@
"build:decl": "tsc --project tsconfig.decl.json",
"build": "pnpm run build:code && pnpm run build:decl",
"prepare": "pnpm run build:code && pnpm run build:decl",
"do-typecheck": "pnpm exec tsc --noEmit"
"do-typecheck": "pnpm exec tsc --noEmit",
"lint": "eslint src",
"lintfix": "eslint --fix src",
"do-lint": "pnpm run lint",
"do-lintfix": "pnpm run lintfix"
},
"exports": {
".": {
@ -35,7 +39,13 @@
},
"homepage": "https://github.com/hoppscotch/hoppscotch#readme",
"devDependencies": {
"@eslint/js": "9.39.2",
"@types/node": "24.9.1",
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.4",
"globals": "16.5.0",
"typescript": "5.9.3",
"vite": "7.3.0"
},

View file

@ -1,16 +1,16 @@
import type { Version } from './type/versioning'
import type { Version } from "./type/versioning"
import { VERSIONS as IO_VERSIONS } from './io'
import { IO_IMPLS as WEB_IO_IMPLS } from './io/impl/web'
import { IO_IMPLS as DESKTOP_IO_IMPLS } from './io/impl/desktop'
import { VERSIONS as IO_VERSIONS } from "./io"
import { IO_IMPLS as WEB_IO_IMPLS } from "./io/impl/web"
import { IO_IMPLS as DESKTOP_IO_IMPLS } from "./io/impl/desktop"
import { VERSIONS as RELAY_VERSIONS } from './relay'
import { RELAY_IMPLS as WEB_RELAY_IMPLS } from './relay/impl/web'
import { RELAY_IMPLS as DESKTOP_RELAY_IMPLS } from './relay/impl/desktop'
import { VERSIONS as RELAY_VERSIONS } from "./relay"
import { RELAY_IMPLS as WEB_RELAY_IMPLS } from "./relay/impl/web"
import { RELAY_IMPLS as DESKTOP_RELAY_IMPLS } from "./relay/impl/desktop"
import { VERSIONS as STORE_VERSIONS } from './store'
import { STORE_IMPLS as WEB_STORE_IMPLS } from './store/impl/web'
import { STORE_IMPLS as DESKTOP_STORE_IMPLS } from './store/impl/desktop'
import { VERSIONS as STORE_VERSIONS } from "./store"
import { STORE_IMPLS as WEB_STORE_IMPLS } from "./store/impl/web"
import { STORE_IMPLS as DESKTOP_STORE_IMPLS } from "./store/impl/desktop"
export interface KernelInfo {
name: string
@ -25,7 +25,7 @@ export interface KernelAPI {
store: typeof STORE_VERSIONS.v1.api
}
export type KernelMode = 'web' | 'desktop'
export type KernelMode = "web" | "desktop"
declare global {
interface Window {
@ -39,16 +39,16 @@ export function getKernelMode(): KernelMode {
}
export function initKernel(mode?: KernelMode): KernelAPI {
if (mode === 'desktop') {
if (mode === "desktop") {
const kernel: KernelAPI = {
info: {
name: "desktop-kernel",
version: { major: 1, minor: 0, patch: 0 },
capabilities: ["basic-io"]
capabilities: ["basic-io"],
},
io: DESKTOP_IO_IMPLS.v1.api,
relay: DESKTOP_RELAY_IMPLS.v1.api,
store: DESKTOP_STORE_IMPLS.v1.api
store: DESKTOP_STORE_IMPLS.v1.api,
}
window.__KERNEL__ = kernel
@ -58,11 +58,11 @@ export function initKernel(mode?: KernelMode): KernelAPI {
info: {
name: "web-kernel",
version: { major: 1, minor: 0, patch: 0 },
capabilities: ["basic-io"]
capabilities: ["basic-io"],
},
io: WEB_IO_IMPLS.v1.api,
relay: WEB_RELAY_IMPLS.v1.api,
store: WEB_STORE_IMPLS.v1.api
store: WEB_STORE_IMPLS.v1.api,
}
window.__KERNEL__ = kernel
@ -79,7 +79,7 @@ export type {
EventCallback,
UnlistenFn,
IoV1,
} from '@io/v/1'
} from "@io/v/1"
export type {
RelayRequest,
@ -98,15 +98,15 @@ export type {
RelayCapabilities,
RelayEventEmitter,
RelayRequestEvents,
StatusCode
} from '@relay/v/1'
StatusCode,
} from "@relay/v/1"
export {
content,
body,
MediaType,
relayRequestToNativeAdapter
} from '@relay/v/1'
relayRequestToNativeAdapter,
} from "@relay/v/1"
export type {
StoreCapability,
@ -120,4 +120,4 @@ export type {
StoredData,
StoreEventEmitter,
StoreV1,
} from '@store/v/1'
} from "@store/v/1"

View file

@ -1,4 +1,4 @@
import { implementation as ioV1 } from './v/1'
import { implementation as ioV1 } from "./v/1"
export const IO_IMPLS = {
v1: ioV1,

View file

@ -1,10 +1,18 @@
import { VersionedAPI } from '@type/versioning'
import { IoV1, SaveFileWithDialogOptions, OpenExternalLinkOptions } from '@io/v/1'
import { VersionedAPI } from "@type/versioning"
import {
IoV1,
SaveFileWithDialogOptions,
OpenExternalLinkOptions,
} from "@io/v/1"
import { save } from "@tauri-apps/plugin-dialog"
import { writeFile, writeTextFile } from "@tauri-apps/plugin-fs"
import { open } from "@tauri-apps/plugin-shell"
import { listen as tauriListen, emit as tauriEmit, Event } from '@tauri-apps/api/event'
import {
listen as tauriListen,
emit as tauriEmit,
Event,
} from "@tauri-apps/api/event"
export const implementation: VersionedAPI<IoV1> = {
version: { major: 1, minor: 0, patch: 0 },
@ -47,6 +55,6 @@ export const implementation: VersionedAPI<IoV1> = {
async emit(event: string, payload?: unknown) {
await tauriEmit(event, payload)
}
}
},
},
}

View file

@ -1,4 +1,4 @@
import { implementation as ioV1 } from './v/1'
import { implementation as ioV1 } from "./v/1"
export const IO_IMPLS = {
v1: ioV1,

View file

@ -1,8 +1,13 @@
import { VersionedAPI } from '@type/versioning'
import { IoV1, SaveFileWithDialogOptions, OpenExternalLinkOptions, Event } from '@io/v/1'
import { pipe } from 'fp-ts/function'
import * as S from 'fp-ts/string'
import * as RNEA from 'fp-ts/ReadonlyNonEmptyArray'
import { VersionedAPI } from "@type/versioning"
import {
IoV1,
SaveFileWithDialogOptions,
OpenExternalLinkOptions,
Event,
} from "@io/v/1"
import { pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
export const implementation: VersionedAPI<IoV1> = {
version: { major: 1, minor: 0, patch: 0 },
@ -20,7 +25,9 @@ export const implementation: VersionedAPI<IoV1> = {
const url = URL.createObjectURL(file)
a.href = url
a.download = opts.suggestedFilename ?? pipe(
a.download =
opts.suggestedFilename ??
pipe(
url,
S.split("/"),
RNEA.last,
@ -49,42 +56,42 @@ export const implementation: VersionedAPI<IoV1> = {
},
async listen<T>(event: string, handler: (event: Event<T>) => void) {
const listener = (e: HashChangeEvent) => {
const listener = (_e: HashChangeEvent) => {
const hash = window.location.hash
if (hash && hash.startsWith(`#${event}:`)) {
const payload = hash.slice(event.length + 2) // Remove #event:
handler({
event,
id: Date.now(),
payload: JSON.parse(payload) as T
payload: JSON.parse(payload) as T,
})
}
}
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
window.addEventListener("hashchange", listener)
return () => window.removeEventListener("hashchange", listener)
},
async once<T>(event: string, handler: (event: Event<T>) => void) {
const listener = (e: HashChangeEvent) => {
const listener = (_e: HashChangeEvent) => {
const hash = window.location.hash
if (hash && hash.startsWith(`#${event}:`)) {
const payload = hash.slice(event.length + 2) // Remove #event:
window.removeEventListener('hashchange', listener)
window.removeEventListener("hashchange", listener)
handler({
event,
id: Date.now(),
payload: JSON.parse(payload) as T
payload: JSON.parse(payload) as T,
})
}
}
window.addEventListener('hashchange', listener)
return () => window.removeEventListener('hashchange', listener)
window.addEventListener("hashchange", listener)
return () => window.removeEventListener("hashchange", listener)
},
async emit(event: string, payload?: unknown) {
window.location.hash = `${event}:${JSON.stringify(payload)}`
}
}
},
},
}

View file

@ -1,4 +1,4 @@
import { v1 } from './v/1'
import { v1 } from "./v/1"
export type {
IoV1,
@ -8,8 +8,8 @@ export type {
OpenExternalLinkResponse,
Event,
EventCallback,
UnlistenFn
} from './v/1'
UnlistenFn,
} from "./v/1"
export const VERSIONS = {
v1,

View file

@ -1,4 +1,4 @@
import type { VersionedAPI } from '@type/versioning'
import type { VersionedAPI } from "@type/versioning"
export interface Event<T> {
event: string
@ -42,20 +42,11 @@ export interface IoV1 {
opts: OpenExternalLinkOptions
) => Promise<OpenExternalLinkResponse>
listen: <T>(
event: string,
handler: EventCallback<T>
) => Promise<UnlistenFn>
listen: <T>(event: string, handler: EventCallback<T>) => Promise<UnlistenFn>
once: <T>(
event: string,
handler: EventCallback<T>
) => Promise<UnlistenFn>
once: <T>(event: string, handler: EventCallback<T>) => Promise<UnlistenFn>
emit: (
event: string,
payload?: unknown
) => Promise<void>
emit: (event: string, payload?: unknown) => Promise<void>
}
export const v1: VersionedAPI<IoV1> = {
@ -66,5 +57,5 @@ export const v1: VersionedAPI<IoV1> = {
listen: async () => () => {},
once: async () => () => {},
emit: async () => {},
}
},
}

View file

@ -1,4 +1,4 @@
import { implementation as relayV1 } from './v/1'
import { implementation as relayV1 } from "./v/1"
export const RELAY_IMPLS = {
v1: relayV1,

View file

@ -1,4 +1,4 @@
import type { VersionedAPI } from '@type/versioning'
import type { VersionedAPI } from "@type/versioning"
import {
type RelayV1,
type RelayRequest,
@ -8,76 +8,61 @@ import {
type RelayError,
body,
relayRequestToNativeAdapter,
} from '@relay/v/1'
import * as E from 'fp-ts/Either'
} from "@relay/v/1"
import * as E from "fp-ts/Either"
import {
execute,
cancel,
type Request,
type RequestResult
} from '@hoppscotch/plugin-relay'
type Request as _Request,
type RequestResult,
} from "@hoppscotch/plugin-relay"
export const implementation: VersionedAPI<RelayV1> = {
version: { major: 1, minor: 0, patch: 0 },
api: {
id: 'desktop',
id: "desktop",
capabilities: {
method: new Set([
'GET',
'POST',
'PUT',
'DELETE',
'PATCH',
'HEAD',
'OPTIONS'
]),
header: new Set([
'stringvalue',
'arrayvalue',
'multivalue'
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"HEAD",
"OPTIONS",
]),
header: new Set(["stringvalue", "arrayvalue", "multivalue"]),
content: new Set([
'text',
'json',
'xml',
'form',
'binary',
'multipart',
'urlencoded',
'stream',
'compression'
]),
auth: new Set([
'basic',
'bearer',
'digest',
'oauth2',
'apikey'
"text",
"json",
"xml",
"form",
"binary",
"multipart",
"urlencoded",
"stream",
"compression",
]),
auth: new Set(["basic", "bearer", "digest", "oauth2", "apikey"]),
security: new Set([
'clientcertificates',
'cacertificates',
'certificatevalidation',
'hostverification',
'peerverification'
]),
proxy: new Set([
'http',
'https',
'authentication',
'certificates'
"clientcertificates",
"cacertificates",
"certificatevalidation",
"hostverification",
"peerverification",
]),
proxy: new Set(["http", "https", "authentication", "certificates"]),
advanced: new Set([
'retry',
'redirects',
'timeout',
'cookies',
'keepalive',
'tcpoptions',
'http2',
'http3'
])
"retry",
"redirects",
"timeout",
"cookies",
"keepalive",
"tcpoptions",
"http2",
"http3",
]),
},
canHandle(request: RelayRequest) {
@ -86,16 +71,19 @@ export const implementation: VersionedAPI<RelayV1> = {
kind: "unsupported_feature",
feature: "method",
message: `Method ${request.method} is not supported`,
relay: "desktop"
relay: "desktop",
})
}
if (request.content && !this.capabilities.content.has(request.content.kind)) {
if (
request.content &&
!this.capabilities.content.has(request.content.kind)
) {
return E.left({
kind: "unsupported_feature",
feature: "content",
message: `Content type ${request.content.kind} is not supported`,
relay: "desktop"
relay: "desktop",
})
}
@ -104,25 +92,33 @@ export const implementation: VersionedAPI<RelayV1> = {
kind: "unsupported_feature",
feature: "authentication",
message: `Authentication type ${request.auth.kind} is not supported`,
relay: "desktop"
relay: "desktop",
})
}
if (request.security?.certificates && !this.capabilities.security.has('clientcertificates')) {
if (
request.security?.certificates &&
!this.capabilities.security.has("clientcertificates")
) {
return E.left({
kind: "unsupported_feature",
feature: "security",
message: "Client certificates are not supported",
relay: "desktop"
relay: "desktop",
})
}
if (request.proxy && !this.capabilities.proxy.has(request.proxy.url.startsWith('https') ? 'https' : 'http')) {
if (
request.proxy &&
!this.capabilities.proxy.has(
request.proxy.url.startsWith("https") ? "https" : "http"
)
) {
return E.left({
kind: "unsupported_feature",
feature: "proxy",
message: `Proxy protocol ${request.proxy.url.split(':')[0]} is not supported`,
relay: "desktop"
message: `Proxy protocol ${request.proxy.url.split(":")[0]} is not supported`,
relay: "desktop",
})
}
@ -133,11 +129,11 @@ export const implementation: VersionedAPI<RelayV1> = {
const emitter: RelayEventEmitter<RelayRequestEvents> = {
on: () => () => {},
once: () => () => {},
off: () => {}
off: () => {},
}
const responsePromise = relayRequestToNativeAdapter(request)
.then(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
@ -160,7 +156,7 @@ export const implementation: VersionedAPI<RelayV1> = {
return execute(pluginRequest)
})
.then((result: RequestResult): E.Either<RelayError, RelayResponse> => {
if (result.kind === 'success') {
if (result.kind === "success") {
const response: RelayResponse = {
id: result.response.id,
status: result.response.status,
@ -168,14 +164,17 @@ export const implementation: VersionedAPI<RelayV1> = {
version: result.response.version,
headers: result.response.headers,
cookies: result.response.cookies,
body: body.body(result.response.body.body, result.response.body.mediaType),
body: body.body(
result.response.body.body,
result.response.body.mediaType
),
meta: {
timing: {
start: result.response.meta.timing.start,
end: result.response.meta.timing.end,
},
size: result.response.meta.size,
}
},
}
return E.right(response)
}
@ -183,18 +182,21 @@ export const implementation: VersionedAPI<RelayV1> = {
})
.catch((error: unknown): E.Either<RelayError, RelayResponse> => {
const networkError: RelayError = {
kind: 'network',
message: error instanceof Error ? error.message : 'Unknown error occurred',
cause: error
kind: "network",
message:
error instanceof Error ? error.message : "Unknown error occurred",
cause: error,
}
return E.left(networkError)
})
return {
cancel: async () => { await cancel(request.id) },
cancel: async () => {
await cancel(request.id)
},
emitter,
response: responsePromise
}
}
response: responsePromise,
}
},
},
}

View file

@ -1,4 +1,4 @@
import { implementation as relayV1 } from './v/1'
import { implementation as relayV1 } from "./v/1"
export const RELAY_IMPLS = {
v1: relayV1,

View file

@ -10,7 +10,7 @@ import {
} from "@relay/v/1"
import type { VersionedAPI } from "@type/versioning"
import { AwsV4Signer } from "aws4fetch"
import { AwsV4Signer as _AwsV4Signer } from "aws4fetch"
import axios, { AxiosRequestConfig } from "axios"
import * as E from "fp-ts/Either"

View file

@ -1,8 +1,6 @@
import { v1 } from './v/1'
import { v1 } from "./v/1"
export type {
RelayV1,
} from './v/1'
export type { RelayV1 } from "./v/1"
export const VERSIONS = {
v1,

View file

@ -1,12 +1,12 @@
import { Request, Response } from '@hoppscotch/plugin-relay'
import type { VersionedAPI } from '@type/versioning'
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'
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
export type Method =
| "GET" // Retrieve resource
@ -101,7 +101,7 @@ export enum MediaType {
TEXT_XML = "text/xml",
APPLICATION_FORM = "application/x-www-form-urlencoded",
APPLICATION_OCTET = "application/octet-stream",
MULTIPART_FORM = "multipart/form-data"
MULTIPART_FORM = "multipart/form-data",
}
export type ContentType =
@ -109,13 +109,18 @@ export type ContentType =
| { kind: "json"; content: unknown; mediaType: MediaType | string }
| { kind: "xml"; content: string; mediaType: MediaType | string }
| { kind: "form"; content: FormData; mediaType: MediaType | string }
| { kind: "binary"; content: Uint8Array; mediaType: MediaType | string; filename?: string }
| {
kind: "binary"
content: Uint8Array
mediaType: MediaType | string
filename?: string
}
| { kind: "multipart"; content: FormData; mediaType: MediaType | string }
| { kind: "urlencoded"; content: string; mediaType: MediaType | string }
| { kind: "stream"; content: ReadableStream; mediaType: MediaType | string }
// TODO: Considering adding a "raw" kind for explicit pass-through content in the future,
// not required at the moment tho, needs some plumbing on the relay side.
// | { kind: "raw"; content: string; mediaType: MediaType | string }
// TODO: Considering adding a "raw" kind for explicit pass-through content in the future,
// not required at the moment tho, needs some plumbing on the relay side.
// | { kind: "raw"; content: string; mediaType: MediaType | string }
export interface RelayResponseBody {
body: Uint8Array
@ -198,15 +203,21 @@ export type CertificateType =
export interface RelayRequestEvents {
progress: {
phase: 'upload' | 'download'
phase: "upload" | "download"
loaded: number
total?: number
}
stateChange: {
state: 'preparing' | 'connecting' | 'sending' | 'waiting' | 'receiving' | 'done'
state:
| "preparing"
| "connecting"
| "sending"
| "waiting"
| "receiving"
| "done"
}
authChallenge: {
type: 'basic' | 'digest' | 'oauth2'
type: "basic" | "digest" | "oauth2"
params: Record<string, string>
}
cookieReceived: {
@ -217,84 +228,84 @@ export interface RelayRequestEvents {
expires?: Date
secure?: boolean
httpOnly?: boolean
sameSite?: 'Strict' | 'Lax' | 'None'
sameSite?: "Strict" | "Lax" | "None"
}
error: {
phase: 'preparation' | 'connection' | 'request' | 'response'
phase: "preparation" | "connection" | "request" | "response"
error: RelayError
}
}
export type RelayEventEmitter<T> = {
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void
once<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void
once<K extends keyof T>(
event: K,
handler: (payload: T[K]) => void
): () => void
off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void
}
// NOTE: RelayCapabilities and their corresponding objects being two separate types
// even with sometimes identical contents is intentional.
export type MethodCapability =
| 'GET'
| 'POST'
| 'PUT'
| 'DELETE'
| 'PATCH'
| 'HEAD'
| 'OPTIONS'
| 'CONNECT'
| 'TRACE'
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "PATCH"
| "HEAD"
| "OPTIONS"
| "CONNECT"
| "TRACE"
export type HeaderCapability =
| 'stringvalue'
| 'arrayvalue'
| 'multivalue'
export type HeaderCapability = "stringvalue" | "arrayvalue" | "multivalue"
export type ContentCapability =
| 'text'
| 'json'
| 'xml'
| 'form'
| 'binary'
| 'multipart'
| 'urlencoded'
| 'stream'
| 'compression'
| "text"
| "json"
| "xml"
| "form"
| "binary"
| "multipart"
| "urlencoded"
| "stream"
| "compression"
export type AuthCapability =
| 'none'
| 'basic'
| 'bearer'
| 'digest'
| 'oauth2'
| 'apikey'
| 'aws'
| 'mtls'
| "none"
| "basic"
| "bearer"
| "digest"
| "oauth2"
| "apikey"
| "aws"
| "mtls"
export type SecurityCapability =
| 'clientcertificates'
| 'cacertificates'
| 'certificatevalidation'
| 'hostverification'
| 'peerverification'
| "clientcertificates"
| "cacertificates"
| "certificatevalidation"
| "hostverification"
| "peerverification"
export type ProxyCapability =
| 'http'
| 'https'
| 'socks'
| 'authentication'
| 'certificates'
| "http"
| "https"
| "socks"
| "authentication"
| "certificates"
export type AdvancedCapability =
| 'retry'
| 'redirects'
| 'timeout'
| 'cookies'
| 'keepalive'
| 'tcpoptions'
| 'ipv6'
| 'http2'
| 'http3'
| 'localaccess'
| "retry"
| "redirects"
| "timeout"
| "cookies"
| "keepalive"
| "tcpoptions"
| "ipv6"
| "http2"
| "http3"
| "localaccess"
export interface RelayCapabilities {
method: Set<MethodCapability>
@ -403,7 +414,7 @@ export interface RelayResponse {
expires?: Date
secure?: boolean
httpOnly?: boolean
sameSite?: 'Strict' | 'Lax' | 'None'
sameSite?: "Strict" | "Lax" | "None"
}>
body: RelayResponseBody
@ -457,17 +468,17 @@ export interface RelayV1 {
canHandle(request: RelayRequest): E.Either<UnsupportedFeatureError, true>
execute(
request: RelayRequest
): {
execute(request: RelayRequest): {
cancel: () => Promise<void>
emitter: RelayEventEmitter<RelayRequestEvents>
response: Promise<E.Either<RelayError, RelayResponse>>
}
}
export const hasCapability = <T>(capabilities: Set<T>, capability: T): boolean =>
capabilities.has(capability)
export const hasCapability = <T>(
capabilities: Set<T>,
capability: T
): boolean => capabilities.has(capability)
export function findSuitableRelay(
request: RelayRequest,
@ -481,9 +492,9 @@ export function findSuitableRelay(
}
const errors = relays
.map(i => i.canHandle(request))
.map((i) => i.canHandle(request))
.filter(E.isLeft)
.map(e => e.left)
.map((e) => e.left)
return E.left(errors[0])
}
@ -494,9 +505,11 @@ export const body = {
contentType?: MediaType | string
): RelayResponseBody => ({
body: new Uint8Array(body),
mediaType: typeof contentType === 'string'
? Object.values(MediaType).find(t => contentType.includes(t)) ?? MediaType.APPLICATION_OCTET
: contentType ?? MediaType.APPLICATION_OCTET
mediaType:
typeof contentType === "string"
? (Object.values(MediaType).find((t) => contentType.includes(t)) ??
MediaType.APPLICATION_OCTET)
: (contentType ?? MediaType.APPLICATION_OCTET),
}),
}
@ -511,22 +524,18 @@ export const transform = {
urlencoded: (arg: string | Record<string, any>): string =>
pipe(
arg,
(input) => typeof input === 'string'
? O.some(input)
: O.none,
(input) => (typeof input === "string" ? O.some(input) : O.none),
O.getOrElse(() => {
const params = new URLSearchParams()
const obj = arg as Record<string, any>
Object.entries(obj)
.filter(([_, value]) => value !== undefined && value !== null)
.forEach(([key, value]) =>
params.append(key, value.toString())
)
.forEach(([key, value]) => params.append(key, value.toString()))
return params.toString()
})
)
),
}
/**
@ -552,13 +561,10 @@ export const content = {
* - Pre-stringified JSON (to avoid encoding escapes)
* - Custom text formats
*/
text: (
content: string,
mediaType?: MediaType | string
): ContentType => ({
text: (content: string, mediaType?: MediaType | string): ContentType => ({
kind: "text",
content: transform.text(content),
mediaType: mediaType ?? MediaType.TEXT_PLAIN
mediaType: mediaType ?? MediaType.TEXT_PLAIN,
}),
/**
@ -566,25 +572,19 @@ export const content = {
* Note: If you already have a JSON string, consider using `text()`
* with `APPLICATION_JSON` mediaType to avoid double-encoding.
*/
json: <T>(
content: T,
mediaType?: MediaType | string
): ContentType => ({
json: <T>(content: T, mediaType?: MediaType | string): ContentType => ({
kind: "json",
content: transform.json(content),
mediaType: mediaType ?? MediaType.APPLICATION_JSON
mediaType: mediaType ?? MediaType.APPLICATION_JSON,
}),
/**
* Creates XML content. Currently processed as text.
*/
xml: (
content: string,
mediaType?: MediaType | string
): ContentType => ({
xml: (content: string, mediaType?: MediaType | string): ContentType => ({
kind: "xml",
content: transform.xml(content),
mediaType: mediaType ?? MediaType.APPLICATION_XML
mediaType: mediaType ?? MediaType.APPLICATION_XML,
}),
/**
@ -593,7 +593,7 @@ export const content = {
form: (content: FormData, mediaType?: MediaType | string): ContentType => ({
kind: "form",
content: transform.form(content),
mediaType: mediaType ?? MediaType.APPLICATION_FORM
mediaType: mediaType ?? MediaType.APPLICATION_FORM,
}),
/**
@ -607,35 +607,44 @@ export const content = {
kind: "binary",
content: transform.binary(content),
mediaType,
filename
filename,
}),
/**
* Creates multipart form content with file upload support.
*/
multipart: (content: FormData, mediaType?: MediaType | string): ContentType => ({
multipart: (
content: FormData,
mediaType?: MediaType | string
): ContentType => ({
kind: "multipart",
content: transform.multipart(content),
mediaType: mediaType ?? MediaType.MULTIPART_FORM
mediaType: mediaType ?? MediaType.MULTIPART_FORM,
}),
/**
* Creates URL-encoded content from string or object.
*/
urlencoded: (content: string | Record<string, any>, mediaType?: MediaType | string): ContentType => ({
urlencoded: (
content: string | Record<string, any>,
mediaType?: MediaType | string
): ContentType => ({
kind: "urlencoded",
content: transform.urlencoded(content),
mediaType: mediaType ?? MediaType.APPLICATION_FORM
mediaType: mediaType ?? MediaType.APPLICATION_FORM,
}),
/**
* Creates streaming content for large payloads.
*/
stream: (content: ReadableStream, mediaType: MediaType | string): ContentType => ({
stream: (
content: ReadableStream,
mediaType: MediaType | string
): ContentType => ({
kind: "stream",
content: transform.stream(content),
mediaType
})
mediaType,
}),
// TODO: Raw content type for pass-through scenarios:
// raw: (content: string, mediaType: MediaType | string): ContentType => ({
@ -680,22 +689,21 @@ export const examples = {
// Custom XML schema
soapXml: content.xml(
'<soap:Envelope>...</soap:Envelope>',
"<soap:Envelope>...</soap:Envelope>",
"application/soap+xml"
),
// Custom binary format
customBinary: content.binary(
new Uint8Array([0x89, 0x50, 0x4E, 0x47]),
new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
"image/png"
),
// Backwards compatible - uses defaults
standardJson: content.json({ name: "John" }),
standardText: content.text("Hello world")
standardText: content.text("Hello world"),
}
/**
* Helper function to convert standard `FormData` to array of arrays `[string, FormDataValue[]][]`
*
@ -713,7 +721,9 @@ export const examples = {
* See: https://datatracker.ietf.org/doc/html/rfc7578#section-5.2
* > Form processors given forms with a well-defined ordering SHOULD send back results in order.
*/
const makeFormDataSerializable = async (formData: FormData): Promise<[string, FormDataValue[]][]> => {
const makeFormDataSerializable = async (
formData: FormData
): Promise<[string, FormDataValue[]][]> => {
const m = new Map<string, FormDataValue[]>()
// @ts-expect-error: `formData.entries` does exist but isn't visible,
@ -725,7 +735,7 @@ const makeFormDataSerializable = async (formData: FormData): Promise<[string, Fo
kind: "file",
filename: value instanceof File ? value.name : "unknown",
contentType: value.type || "application/octet-stream",
data: new Uint8Array(buffer)
data: new Uint8Array(buffer),
}
if (m.has(key)) {
@ -736,7 +746,7 @@ const makeFormDataSerializable = async (formData: FormData): Promise<[string, Fo
} else {
const textEntry: FormDataValue = {
kind: "text",
value: value.toString()
value: value.toString(),
}
if (m.has(key)) {
@ -751,11 +761,18 @@ const makeFormDataSerializable = async (formData: FormData): Promise<[string, Fo
}
// Helper function to adapt a relay request to work with the plugin
export const relayRequestToNativeAdapter = async (request: RelayRequest): Promise<Request> => {
const adaptedRequest = { ...request };
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);
if (
adaptedRequest.content?.kind === "multipart" &&
adaptedRequest.content.content instanceof FormData
) {
const serializableFormData = await makeFormDataSerializable(
adaptedRequest.content.content
)
adaptedRequest.content = {
...adaptedRequest.content,
@ -766,11 +783,11 @@ export const relayRequestToNativeAdapter = async (request: RelayRequest): Promis
// `Maps` it seems like are serialized differently across platforms and serialization libraries,
// while `Array` of `Array` tend to maintain more consistent behavior by the sheer ubiquity of it.
// @ts-expect-error: This is intentional to work around SuperJSON serialization
content: serializableFormData
};
content: serializableFormData,
}
}
return adaptedRequest as Request;
return adaptedRequest as Request
}
export const v1: VersionedAPI<RelayV1> = {
@ -784,27 +801,28 @@ export const v1: VersionedAPI<RelayV1> = {
auth: new Set<AuthCapability>(),
security: new Set<SecurityCapability>(),
proxy: new Set<ProxyCapability>(),
advanced: new Set<AdvancedCapability>()
advanced: new Set<AdvancedCapability>(),
},
canHandle: () => E.left({
canHandle: () =>
E.left({
kind: "unsupported_feature",
feature: "execution",
message: "Default relay cannot handle requests",
relay: "default"
relay: "default",
}),
execute: () => ({
cancel: async () => {},
emitter: {
on: () => () => {},
once: () => () => {},
off: () => {}
off: () => {},
},
response: Promise.resolve(
E.left({
kind: "version",
message: "Not implemented"
message: "Not implemented",
})
)
})
}
),
}),
},
}

View file

@ -1,4 +1,4 @@
import { implementation as storeV1 } from './v/1'
import { implementation as storeV1 } from "./v/1"
export const STORE_IMPLS = {
v1: storeV1,

View file

@ -1,8 +1,8 @@
import * as E from 'fp-ts/Either';
import * as E from "fp-ts/Either"
import { Store } from '@tauri-apps/plugin-store';
import { Store } from "@tauri-apps/plugin-store"
import { VersionedAPI } from '@type/versioning';
import { VersionedAPI } from "@type/versioning"
import {
StoreV1,
StoreError,
@ -11,187 +11,204 @@ import {
StoredData,
StoredDataSchema,
StoreEventEmitter,
} from '@store/v/1';
} from "@store/v/1"
type NamespacedData = Record<string, Record<string, StoredData>>;
type NamespacedData = Record<string, Record<string, StoredData>>
class TauriStoreManager {
private static instances: Map<string, TauriStoreManager> = new Map();
private store: Store | null = null;
private listeners = new Map<string, Set<(payload: StoreEvents['change']) => void>>();
private data: NamespacedData = {};
private storePath: string;
private static instances: Map<string, TauriStoreManager> = new Map()
private store: Store | null = null
private listeners = new Map<
string,
Set<(payload: StoreEvents["change"]) => void>
>()
private data: NamespacedData = {}
private storePath: string
private constructor(storePath: string) {
this.storePath = storePath;
this.storePath = storePath
}
static new(storePath: string): TauriStoreManager {
if (TauriStoreManager.instances.has(storePath)) {
return TauriStoreManager.instances.get(storePath)!;
return TauriStoreManager.instances.get(storePath)!
}
const instance = new TauriStoreManager(storePath);
TauriStoreManager.instances.set(storePath, instance);
return instance;
const instance = new TauriStoreManager(storePath)
TauriStoreManager.instances.set(storePath, instance)
return instance
}
static async closeAll(): Promise<void> {
const closePromises = Array.from(TauriStoreManager.instances.values())
.map(instance => instance.close());
await Promise.all(closePromises);
TauriStoreManager.instances.clear();
const closePromises = Array.from(TauriStoreManager.instances.values()).map(
(instance) => instance.close()
)
await Promise.all(closePromises)
TauriStoreManager.instances.clear()
}
static async closeStore(storePath: string): Promise<void> {
const instance = TauriStoreManager.instances.get(storePath);
const instance = TauriStoreManager.instances.get(storePath)
if (instance) {
await instance.close();
TauriStoreManager.instances.delete(storePath);
await instance.close()
TauriStoreManager.instances.delete(storePath)
}
}
async init(): Promise<void> {
if (!this.store) {
this.store = await Store.load(this.storePath);
const loadedData = await this.store.get<NamespacedData>('data');
this.data = loadedData ?? {};
this.store = await Store.load(this.storePath)
const loadedData = await this.store.get<NamespacedData>("data")
this.data = loadedData ?? {}
this.store.onChange((_, value: NamespacedData | undefined) => {
if (value) {
this.data = value;
this.notifyListeners();
this.data = value
this.notifyListeners()
}
});
})
}
}
private notifyListeners(): void {
for (const [key, listeners] of this.listeners.entries()) {
const [namespace, dataKey] = key.split(':');
const value = this.data[namespace]?.[dataKey];
listeners.forEach(listener =>
const [namespace, dataKey] = key.split(":")
const value = this.data[namespace]?.[dataKey]
listeners.forEach((listener) =>
listener({
namespace,
key: dataKey,
value: value?.data,
})
);
)
}
}
async set(namespace: string, key: string, value: StoredData): Promise<void> {
if (!this.store) throw new Error('Store not initialized');
if (!this.store) throw new Error("Store not initialized")
const validated = StoredDataSchema.parse(value);
this.data[namespace] = this.data[namespace] || {};
this.data[namespace][key] = validated;
await this.store.set('data', this.data);
await this.store.save();
const validated = StoredDataSchema.parse(value)
this.data[namespace] = this.data[namespace] || {}
this.data[namespace][key] = validated
await this.store.set("data", this.data)
await this.store.save()
}
async getRaw(namespace: string, key: string): Promise<StoredData | undefined> {
const rawValue = this.data[namespace]?.[key];
if (!rawValue) return undefined;
async getRaw(
namespace: string,
key: string
): Promise<StoredData | undefined> {
const rawValue = this.data[namespace]?.[key]
if (!rawValue) return undefined
const validated = StoredDataSchema.parse(rawValue);
return validated;
const validated = StoredDataSchema.parse(rawValue)
return validated
}
async get<T>(namespace: string, key: string): Promise<T | undefined> {
const storedData = await this.getRaw(namespace, key);
return storedData?.data as T | undefined;
const storedData = await this.getRaw(namespace, key)
return storedData?.data as T | undefined
}
async has(namespace: string, key: string): Promise<boolean> {
return !!this.data[namespace]?.[key];
return !!this.data[namespace]?.[key]
}
async delete(namespace: string, key: string): Promise<boolean> {
if (!this.store) throw new Error('Store not initialized');
if (!this.store) throw new Error("Store not initialized")
if (this.data[namespace]?.[key]) {
delete this.data[namespace][key];
delete this.data[namespace][key]
if (Object.keys(this.data[namespace]).length === 0) {
delete this.data[namespace];
delete this.data[namespace]
}
await this.store.set('data', this.data);
await this.store.save();
return true;
await this.store.set("data", this.data)
await this.store.save()
return true
}
return false;
return false
}
async clear(namespace?: string): Promise<void> {
if (!this.store) throw new Error('Store not initialized');
if (!this.store) throw new Error("Store not initialized")
if (namespace) {
delete this.data[namespace];
delete this.data[namespace]
} else {
this.data = {};
this.data = {}
}
await this.store.set('data', this.data);
await this.store.save();
await this.store.set("data", this.data)
await this.store.save()
}
async listNamespaces(): Promise<string[]> {
return Object.keys(this.data);
return Object.keys(this.data)
}
async listKeys(namespace: string): Promise<string[]> {
return Object.keys(this.data[namespace] || {});
return Object.keys(this.data[namespace] || {})
}
async watch(namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const watchKey = `${namespace}:${key}`;
async watch(
namespace: string,
key: string
): Promise<StoreEventEmitter<StoreEvents>> {
const watchKey = `${namespace}:${key}`
return {
on: <K extends keyof StoreEvents>(
event: K,
handler: (payload: StoreEvents[K]) => void
) => {
if (event !== 'change') return () => {};
if (event !== "change") return () => {}
if (!this.listeners.has(watchKey)) {
this.listeners.set(watchKey, new Set());
this.listeners.set(watchKey, new Set())
}
this.listeners.get(watchKey)!.add(handler as (payload: StoreEvents['change']) => void);
return () => this.listeners.get(watchKey)?.delete(handler as (payload: StoreEvents['change']) => void);
this.listeners
.get(watchKey)!
.add(handler as (payload: StoreEvents["change"]) => void)
return () =>
this.listeners
.get(watchKey)
?.delete(handler as (payload: StoreEvents["change"]) => void)
},
once: <K extends keyof StoreEvents>(
event: K,
handler: (payload: StoreEvents[K]) => void
) => {
if (event !== 'change') return () => {};
if (event !== "change") return () => {}
const wrapper = (value: StoreEvents['change']) => {
handler(value as StoreEvents[K]);
this.listeners.get(watchKey)?.delete(wrapper);
};
const wrapper = (value: StoreEvents["change"]) => {
handler(value as StoreEvents[K])
this.listeners.get(watchKey)?.delete(wrapper)
}
if (!this.listeners.has(watchKey)) {
this.listeners.set(watchKey, new Set());
this.listeners.set(watchKey, new Set())
}
this.listeners.get(watchKey)!.add(wrapper);
return () => this.listeners.get(watchKey)?.delete(wrapper);
this.listeners.get(watchKey)!.add(wrapper)
return () => this.listeners.get(watchKey)?.delete(wrapper)
},
off: <K extends keyof StoreEvents>(
event: K,
handler: (payload: StoreEvents[K]) => void
) => {
if (event === 'change') {
this.listeners.get(watchKey)?.delete(handler as (payload: StoreEvents['change']) => void);
if (event === "change") {
this.listeners
.get(watchKey)
?.delete(handler as (payload: StoreEvents["change"]) => void)
}
},
};
}
}
async close(): Promise<void> {
if (this.store) {
await this.store.close();
this.store = null;
this.data = {};
this.listeners.clear();
TauriStoreManager.instances.delete(this.storePath);
await this.store.close()
this.store = null
this.data = {}
this.listeners.clear()
TauriStoreManager.instances.delete(this.storePath)
}
}
}
@ -199,28 +216,41 @@ class TauriStoreManager {
export const implementation: VersionedAPI<StoreV1> = {
version: { major: 1, minor: 0, patch: 0 },
api: {
id: 'tauri-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace', 'secure']),
id: "tauri-store",
capabilities: new Set([
"permanent",
"structured",
"watch",
"namespace",
"secure",
]),
async init(storePath: string) {
try {
const manager = TauriStoreManager.new(storePath);
await manager.init();
return E.right(undefined);
const manager = TauriStoreManager.new(storePath)
await manager.init()
return E.right(undefined)
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async set(storePath: string, namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>> {
async set(
storePath: string,
namespace: string,
key: string,
value: unknown,
options?: StorageOptions
): Promise<E.Either<StoreError, void>> {
try {
const manager = TauriStoreManager.new(storePath);
const existingData = await manager.getRaw(namespace, key);
const createdAt = existingData?.metadata.createdAt || new Date().toISOString()
const manager = TauriStoreManager.new(storePath)
const existingData = await manager.getRaw(namespace, key)
const createdAt =
existingData?.metadata.createdAt || new Date().toISOString()
const updatedAt = new Date().toISOString()
const storedData: StoredData = {
@ -234,101 +264,125 @@ export const implementation: VersionedAPI<StoreV1> = {
ttl: options?.ttl,
},
data: value,
};
}
await manager.set(namespace, key, storedData);
return E.right(undefined);
await manager.set(namespace, key, storedData)
return E.right(undefined)
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async get<T>(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>> {
async get<T>(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, T | undefined>> {
try {
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.get<T>(namespace, key));
const manager = TauriStoreManager.new(storePath)
return E.right(await manager.get<T>(namespace, key))
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async has(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
async has(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> {
try {
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.has(namespace, key));
const manager = TauriStoreManager.new(storePath)
return E.right(await manager.has(namespace, key))
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async remove(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>> {
async remove(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>> {
try {
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.delete(namespace, key));
const manager = TauriStoreManager.new(storePath)
return E.right(await manager.delete(namespace, key))
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async clear(storePath: string, namespace?: string): Promise<E.Either<StoreError, void>> {
async clear(
storePath: string,
namespace?: string
): Promise<E.Either<StoreError, void>> {
try {
const manager = TauriStoreManager.new(storePath);
await manager.clear(namespace);
return E.right(undefined);
const manager = TauriStoreManager.new(storePath)
await manager.clear(namespace)
return E.right(undefined)
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async listNamespaces(storePath: string): Promise<E.Either<StoreError, string[]>> {
async listNamespaces(
storePath: string
): Promise<E.Either<StoreError, string[]>> {
try {
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listNamespaces());
const manager = TauriStoreManager.new(storePath)
return E.right(await manager.listNamespaces())
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async listKeys(storePath: string, namespace: string): Promise<E.Either<StoreError, string[]>> {
async listKeys(
storePath: string,
namespace: string
): Promise<E.Either<StoreError, string[]>> {
try {
const manager = TauriStoreManager.new(storePath);
return E.right(await manager.listKeys(namespace));
const manager = TauriStoreManager.new(storePath)
return E.right(await manager.listKeys(namespace))
} catch (error) {
return E.left({
kind: 'storage',
message: error instanceof Error ? error.message : 'Unknown error',
kind: "storage",
message: error instanceof Error ? error.message : "Unknown error",
cause: error,
});
})
}
},
async watch(storePath: string, namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const manager = TauriStoreManager.new(storePath);
return manager.watch(namespace, key);
async watch(
storePath: string,
namespace: string,
key: string
): Promise<StoreEventEmitter<StoreEvents>> {
const manager = TauriStoreManager.new(storePath)
return manager.watch(namespace, key)
},
},
};
}

View file

@ -1,4 +1,4 @@
import { implementation as storeV1 } from './v/1'
import { implementation as storeV1 } from "./v/1"
export const STORE_IMPLS = {
v1: storeV1,

View file

@ -1,154 +1,176 @@
import * as E from 'fp-ts/Either';
import superjson from 'superjson';
import * as E from "fp-ts/Either"
import superjson from "superjson"
import type { VersionedAPI } from '@type/versioning';
import type { VersionedAPI } from "@type/versioning"
import {
StoreV1,
StoredData,
StoredDataSchema,
StoreEvents,
StoreEventEmitter,
} from '@store/v/1';
} from "@store/v/1"
class BrowserStoreManager {
private static instance: BrowserStoreManager;
private listeners = new Map<string, Set<(payload: StoreEvents['change']) => void>>();
private static instance: BrowserStoreManager
private listeners = new Map<
string,
Set<(payload: StoreEvents["change"]) => void>
>()
private constructor() {}
static new(): BrowserStoreManager {
if (!BrowserStoreManager.instance) {
BrowserStoreManager.instance = new BrowserStoreManager();
BrowserStoreManager.instance = new BrowserStoreManager()
}
return BrowserStoreManager.instance;
return BrowserStoreManager.instance
}
private getFullKey(namespace: string, key: string): string {
return `${namespace}:${key}`;
return `${namespace}:${key}`
}
private notifyListeners(namespace: string, key: string, value?: unknown) {
const fullKey = this.getFullKey(namespace, key);
const listeners = this.listeners.get(fullKey) || new Set();
listeners.forEach(listener => listener({ namespace, key, value }));
const fullKey = this.getFullKey(namespace, key)
const listeners = this.listeners.get(fullKey) || new Set()
listeners.forEach((listener) => listener({ namespace, key, value }))
}
async set(namespace: string, key: string, value: StoredData): Promise<void> {
const validated = StoredDataSchema.parse(value);
localStorage.setItem(this.getFullKey(namespace, key), superjson.stringify(validated));
this.notifyListeners(namespace, key, validated.data);
const validated = StoredDataSchema.parse(value)
localStorage.setItem(
this.getFullKey(namespace, key),
superjson.stringify(validated)
)
this.notifyListeners(namespace, key, validated.data)
}
async getRaw(namespace: string, key: string): Promise<StoredData | undefined> {
const rawValue = localStorage.getItem(this.getFullKey(namespace, key));
if (!rawValue) return undefined;
async getRaw(
namespace: string,
key: string
): Promise<StoredData | undefined> {
const rawValue = localStorage.getItem(this.getFullKey(namespace, key))
if (!rawValue) return undefined
const parsed = superjson.parse(rawValue);
const validated = StoredDataSchema.parse(parsed);
return validated;
const parsed = superjson.parse(rawValue)
const validated = StoredDataSchema.parse(parsed)
return validated
}
async get<T>(namespace: string, key: string): Promise<T | undefined> {
const storedData = await this.getRaw(namespace, key);
return storedData?.data as T;
const storedData = await this.getRaw(namespace, key)
return storedData?.data as T
}
async has(namespace: string, key: string): Promise<boolean> {
return localStorage.getItem(this.getFullKey(namespace, key)) !== null;
return localStorage.getItem(this.getFullKey(namespace, key)) !== null
}
async delete(namespace: string, key: string): Promise<boolean> {
const exists = await this.has(namespace, key);
const exists = await this.has(namespace, key)
if (exists) {
localStorage.removeItem(this.getFullKey(namespace, key));
this.notifyListeners(namespace, key, undefined);
localStorage.removeItem(this.getFullKey(namespace, key))
this.notifyListeners(namespace, key, undefined)
}
return exists;
return exists
}
async clear(namespace?: string): Promise<void> {
if (namespace) {
const keysToRemove = Object.keys(localStorage).filter(key => key.startsWith(`${namespace}:`));
keysToRemove.forEach(key => localStorage.removeItem(key));
const keysToRemove = Object.keys(localStorage).filter((key) =>
key.startsWith(`${namespace}:`)
)
keysToRemove.forEach((key) => localStorage.removeItem(key))
} else {
localStorage.clear();
localStorage.clear()
}
this.listeners.clear();
this.listeners.clear()
}
async listNamespaces(): Promise<string[]> {
const namespaces = new Set<string>();
Object.keys(localStorage).forEach(key => {
const [namespace] = key.split(':');
namespaces.add(namespace);
});
return Array.from(namespaces);
const namespaces = new Set<string>()
Object.keys(localStorage).forEach((key) => {
const [namespace] = key.split(":")
namespaces.add(namespace)
})
return Array.from(namespaces)
}
async listKeys(namespace: string): Promise<string[]> {
return Object.keys(localStorage)
.filter(key => key.startsWith(`${namespace}:`))
.map(key => key.replace(`${namespace}:`, ''));
.filter((key) => key.startsWith(`${namespace}:`))
.map((key) => key.replace(`${namespace}:`, ""))
}
async watch(namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>> {
const fullKey = this.getFullKey(namespace, key);
async watch(
namespace: string,
key: string
): Promise<StoreEventEmitter<StoreEvents>> {
const fullKey = this.getFullKey(namespace, key)
return {
on: (event, handler) => {
if (event !== 'change') return () => {};
if (event !== "change") return () => {}
if (!this.listeners.has(fullKey)) {
this.listeners.set(fullKey, new Set());
this.listeners.set(fullKey, new Set())
}
this.listeners.get(fullKey)!.add(handler as (payload: StoreEvents['change']) => void);
return () => this.listeners.get(fullKey)?.delete(handler as (payload: StoreEvents['change']) => void);
this.listeners
.get(fullKey)!
.add(handler as (payload: StoreEvents["change"]) => void)
return () =>
this.listeners
.get(fullKey)
?.delete(handler as (payload: StoreEvents["change"]) => void)
},
once: (event, handler) => {
if (event !== 'change') return () => {};
const wrapper = (payload: StoreEvents['change']) => {
handler(payload);
this.listeners.get(fullKey)?.delete(wrapper);
};
if (!this.listeners.has(fullKey)) {
this.listeners.set(fullKey, new Set());
if (event !== "change") return () => {}
const wrapper = (payload: StoreEvents["change"]) => {
handler(payload)
this.listeners.get(fullKey)?.delete(wrapper)
}
this.listeners.get(fullKey)!.add(wrapper);
return () => this.listeners.get(fullKey)?.delete(wrapper);
if (!this.listeners.has(fullKey)) {
this.listeners.set(fullKey, new Set())
}
this.listeners.get(fullKey)!.add(wrapper)
return () => this.listeners.get(fullKey)?.delete(wrapper)
},
off: (event, handler) => {
if (event === 'change') {
this.listeners.get(fullKey)?.delete(handler as (payload: StoreEvents['change']) => void);
if (event === "change") {
this.listeners
.get(fullKey)
?.delete(handler as (payload: StoreEvents["change"]) => void)
}
},
};
}
}
}
export const implementation: VersionedAPI<StoreV1> = {
version: { major: 1, minor: 0, patch: 0 },
api: {
id: 'browser-store',
capabilities: new Set(['permanent', 'structured', 'watch', 'namespace']),
id: "browser-store",
capabilities: new Set(["permanent", "structured", "watch", "namespace"]),
// `init` and other methods in `web` don't `storePath`
// but having a consistent API where first param of every method
// is the path that filteres to the "realm" makes it easier to reason around
async init(_storePath) {
try {
return E.right(undefined);
return E.right(undefined)
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async set(_storePath, namespace, key, value, options) {
try {
const manager = BrowserStoreManager.new();
const existingData = await manager.getRaw(namespace, key);
const createdAt = existingData?.metadata.createdAt || new Date().toISOString()
const manager = BrowserStoreManager.new()
const existingData = await manager.getRaw(namespace, key)
const createdAt =
existingData?.metadata.createdAt || new Date().toISOString()
const updatedAt = new Date().toISOString()
const storedData: StoredData = {
@ -162,101 +184,101 @@ export const implementation: VersionedAPI<StoreV1> = {
ttl: options?.ttl,
},
data: value,
};
}
await manager.set(namespace, key, storedData);
return E.right(undefined);
await manager.set(namespace, key, storedData)
return E.right(undefined)
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async get(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.get(namespace, key));
const manager = BrowserStoreManager.new()
return E.right(await manager.get(namespace, key))
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async has(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.has(namespace, key));
const manager = BrowserStoreManager.new()
return E.right(await manager.has(namespace, key))
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async remove(_storePath, namespace, key) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.delete(namespace, key));
const manager = BrowserStoreManager.new()
return E.right(await manager.delete(namespace, key))
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async clear(_storePath, namespace) {
try {
const manager = BrowserStoreManager.new();
await manager.clear(namespace);
return E.right(undefined);
const manager = BrowserStoreManager.new()
await manager.clear(namespace)
return E.right(undefined)
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async listNamespaces(_storePath){
async listNamespaces(_storePath) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.listNamespaces());
const manager = BrowserStoreManager.new()
return E.right(await manager.listNamespaces())
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async listKeys(_storePath, namespace) {
try {
const manager = BrowserStoreManager.new();
return E.right(await manager.listKeys(namespace));
const manager = BrowserStoreManager.new()
return E.right(await manager.listKeys(namespace))
} catch (e) {
return E.left({
kind: 'storage',
message: e instanceof Error ? e.message : 'Unknown error',
kind: "storage",
message: e instanceof Error ? e.message : "Unknown error",
cause: e,
});
})
}
},
async watch(_storePath, namespace, key) {
const manager = BrowserStoreManager.new();
return manager.watch(namespace, key);
const manager = BrowserStoreManager.new()
return manager.watch(namespace, key)
},
},
};
}

View file

@ -1,8 +1,6 @@
import { v1 } from './v/1'
import { v1 } from "./v/1"
export type {
StoreV1,
} from './v/1'
export type { StoreV1 } from "./v/1"
export const VERSIONS = {
v1,

View file

@ -1,27 +1,27 @@
import type { VersionedAPI } from '@type/versioning'
import * as E from 'fp-ts/Either'
import { z } from 'zod'
import type { VersionedAPI } from "@type/versioning"
import * as E from "fp-ts/Either"
import { z } from "zod"
export type StoreCapability =
| 'permanent'
| 'temporary'
| 'structured'
| 'sync'
| 'watch'
| 'secure'
| 'namespace'
| "permanent"
| "temporary"
| "structured"
| "sync"
| "watch"
| "secure"
| "namespace"
export type StoreError =
| { kind: 'not_found'; message: string }
| { kind: 'permission'; message: string }
| { kind: 'quota'; message: string }
| { kind: 'version'; message: string }
| { kind: 'parse'; message: string; cause?: unknown }
| { kind: 'storage'; message: string; cause?: unknown }
| { kind: 'encrypt'; message: string; cause?: unknown }
| { kind: "not_found"; message: string }
| { kind: "permission"; message: string }
| { kind: "quota"; message: string }
| { kind: "version"; message: string }
| { kind: "parse"; message: string; cause?: unknown }
| { kind: "storage"; message: string; cause?: unknown }
| { kind: "encrypt"; message: string; cause?: unknown }
export interface StoreFile {
include?: boolean,
include?: boolean
name: string
size: number
@ -48,13 +48,15 @@ export interface StoreEvents {
export const StoreMetadataSchema = z.object({
version: z.number(),
lastUpdated: z.string().datetime(),
namespaces: z.record(z.object({
namespaces: z.record(
z.object({
name: z.string(),
version: z.number(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
keys: z.array(z.string())
}))
keys: z.array(z.string()),
})
),
})
export type StoreMetadata = z.infer<typeof StoreMetadataSchema>
@ -67,16 +69,19 @@ export const StoredDataSchema = z.object({
namespace: z.string(),
encrypted: z.boolean().optional(),
compressed: z.boolean().optional(),
ttl: z.number().optional()
ttl: z.number().optional(),
}),
data: z.unknown()
data: z.unknown(),
})
export type StoredData = z.infer<typeof StoredDataSchema>
export interface StoreEventEmitter<T> {
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void
once<K extends keyof T>(event: K, handler: (payload: T[K]) => void): () => void
once<K extends keyof T>(
event: K,
handler: (payload: T[K]) => void
): () => void
off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void
}
@ -85,34 +90,64 @@ export interface StoreV1 {
readonly capabilities: Set<StoreCapability>
init(storePath: string): Promise<E.Either<StoreError, void>>
set(storePath: string, namespace: string, key: string, value: unknown, options?: StorageOptions): Promise<E.Either<StoreError, void>>
get<T>(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, T | undefined>>
remove(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
clear(storePath: string, namespace?: string): Promise<E.Either<StoreError, void>>
has(storePath: string, namespace: string, key: string): Promise<E.Either<StoreError, boolean>>
set(
storePath: string,
namespace: string,
key: string,
value: unknown,
options?: StorageOptions
): Promise<E.Either<StoreError, void>>
get<T>(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, T | undefined>>
remove(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>>
clear(
storePath: string,
namespace?: string
): Promise<E.Either<StoreError, void>>
has(
storePath: string,
namespace: string,
key: string
): Promise<E.Either<StoreError, boolean>>
listNamespaces(storePath: string): Promise<E.Either<StoreError, string[]>>
listKeys(storePath: string, namespace: string): Promise<E.Either<StoreError, string[]>>
watch(storePath: string, namespace: string, key: string): Promise<StoreEventEmitter<StoreEvents>>
listKeys(
storePath: string,
namespace: string
): Promise<E.Either<StoreError, string[]>>
watch(
storePath: string,
namespace: string,
key: string
): Promise<StoreEventEmitter<StoreEvents>>
}
export const v1: VersionedAPI<StoreV1> = {
version: { major: 1, minor: 0, patch: 0 },
api: {
id: 'default',
id: "default",
capabilities: new Set(),
init: async () => E.left({ kind: 'version', message: 'Not implemented' }),
set: async () => E.left({ kind: 'version', message: 'Not implemented' }),
get: async () => E.left({ kind: 'version', message: 'Not implemented' }),
remove: async () => E.left({ kind: 'version', message: 'Not implemented' }),
clear: async () => E.left({ kind: 'version', message: 'Not implemented' }),
has: async () => E.left({ kind: 'version', message: 'Not implemented' }),
listNamespaces: async () => E.left({ kind: 'version', message: 'Not implemented' }),
listKeys: async () => E.left({ kind: 'version', message: 'Not implemented' }),
init: async () => E.left({ kind: "version", message: "Not implemented" }),
set: async () => E.left({ kind: "version", message: "Not implemented" }),
get: async () => E.left({ kind: "version", message: "Not implemented" }),
remove: async () => E.left({ kind: "version", message: "Not implemented" }),
clear: async () => E.left({ kind: "version", message: "Not implemented" }),
has: async () => E.left({ kind: "version", message: "Not implemented" }),
listNamespaces: async () =>
E.left({ kind: "version", message: "Not implemented" }),
listKeys: async () =>
E.left({ kind: "version", message: "Not implemented" }),
watch: async () => ({
on: () => () => {},
once: () => () => {},
off: () => {}
})
}
off: () => {},
}),
},
}

View file

@ -1,6 +1,9 @@
import type { Version } from '@type/versioning'
import type { Version } from "@type/versioning"
export function checkCapability(required: Version, available: Version): boolean {
export function checkCapability(
required: Version,
available: Version
): boolean {
if (available.major !== required.major) return false
if (available.minor < required.minor) return false
return true

View file

@ -1,73 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
root: true,
env: {
browser: true,
node: true,
jest: true,
},
parserOptions: {
sourceType: "module",
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
extends: [
"@vue/typescript/recommended",
"plugin:vue/recommended",
"plugin:prettier/recommended",
],
ignorePatterns: [
"static/**/*",
"./helpers/backend/graphql.ts",
"**/*.d.ts",
"types/**/*",
],
plugins: ["vue", "prettier"],
// add your custom rules here
rules: {
semi: [2, "never"],
"import/named": "off", // because, named import issue with typescript see: https://github.com/typescript-eslint/typescript-eslint/issues/154
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"import/default": "off",
"no-undef": "off",
// localStorage block
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
},
],
// window.localStorage block
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use localpersistence.ts functions or stores",
},
],
},
}

View file

@ -0,0 +1,85 @@
import pluginVue from "eslint-plugin-vue"
import {
defineConfigWithVueTs,
vueTsConfigs,
} from "@vue/eslint-config-typescript"
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
import globals from "globals"
export default defineConfigWithVueTs(
{
ignores: [
"static/**",
"src/api/generated/**",
"**/*.d.ts",
"types/**",
"dist/**",
"node_modules/**",
],
},
pluginVue.configs["flat/recommended"],
vueTsConfigs.recommended,
eslintPluginPrettierRecommended,
{
files: ["**/*.ts", "**/*.js", "**/*.vue"],
linterOptions: {
reportUnusedDisableDirectives: false,
},
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
requireConfigFile: false,
ecmaFeatures: {
jsx: false,
},
},
},
rules: {
semi: [2, "never"],
"import/named": "off",
"no-console": "off",
"no-debugger": process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"prettier/prettier":
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
"vue/multi-word-component-names": "off",
"vue/no-side-effects-in-computed-properties": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"@typescript-eslint/no-unused-vars": [
process.env.HOPP_LINT_FOR_PROD === "true" ? "error" : "warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-function-type": "off",
"import/default": "off",
"no-undef": "off",
"no-restricted-globals": [
"error",
{
name: "localStorage",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.object.property.name='localStorage']",
message:
"Do not use 'localStorage' directly. Please use the PersistenceService",
},
],
},
}
)

View file

@ -9,9 +9,9 @@
"dev": "pnpm exec npm-run-all -p -l dev:*",
"build": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lint": "eslint src",
"lint:ts": "vue-tsc --noEmit",
"lintfix": "eslint --fix src --ext .ts,.js,.vue --ignore-path .gitignore .",
"lintfix": "eslint --fix src",
"prod-lint": "cross-env HOPP_LINT_FOR_PROD=true pnpm run lint",
"generate": "pnpm run build",
"do-dev": "pnpm run dev",
@ -51,6 +51,8 @@
"zod": "3.25.32"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.3",
"@eslint/js": "9.39.2",
"@graphql-codegen/add": "6.0.0",
"@graphql-codegen/cli": "6.1.0",
"@graphql-codegen/typed-document-node": "6.1.5",
@ -66,13 +68,14 @@
"@typescript-eslint/parser": "8.50.0",
"@vitejs/plugin-legacy": "7.2.1",
"@vitejs/plugin-vue": "6.0.3",
"@vue/eslint-config-typescript": "13.0.0",
"@vue/eslint-config-typescript": "14.6.0",
"autoprefixer": "10.4.23",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint": "8.57.0",
"eslint": "9.39.2",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-vue": "10.6.2",
"globals": "16.5.0",
"npm-run-all": "4.1.5",
"postcss": "8.5.6",
"prettier-plugin-tailwindcss": "0.7.1",

View file

@ -139,7 +139,7 @@ async function getInitialUserDetails(): Promise<
}
}
return { error: "auth/cookies_not_found" }
} catch (error) {
} catch (_error) {
return { error: "auth/cookies_not_found" }
}
}
@ -254,7 +254,7 @@ async function refreshToken() {
}
return isSuccessful
} catch (err) {
} catch (_err) {
return false
}
}

View file

@ -167,7 +167,7 @@ async function refreshToken() {
}
return isSuccessful
} catch (error) {
} catch (_error) {
return false
}
}
@ -394,7 +394,7 @@ export const def: AuthPlatformDef = {
// axios automatically throws on error status codes, so if we reach here, it was successful
return !!response.data.isValid
} catch (error) {
} catch (_error) {
return false
}
},

File diff suppressed because it is too large Load diff