From 5c14ddfa81094b287a544838d66ab0f45f874035 Mon Sep 17 00:00:00 2001 From: Jay Wu Date: Thu, 22 Oct 2026 14:00:01 +0800 Subject: [PATCH 1/7] fix(vul): update grpc reflection deps (#10642) * chore: update google-protobuf to 4.0.3 (INS-3888) google-protobuf <4.0.3 has no depth limit when skipping unknown group fields during binary deserialization, so a crafted payload of nested START_GROUP tags overflows the call stack (AIKIDO-2026-834673). It is only a transitive dependency (grpc-reflection-js at runtime, @ravanallc/grpc-server-reflection in the smoke tests) and both declare a ^3.x range, so force 4.0.3 through root overrides. npm does not apply the global override to the @ravanallc edge, hence the scoped override. Co-Authored-By: Claude Sonnet 5.5 * chore: replace @ravanallc/grpc-server-reflection with @grpc/reflection (INS-3888) @ravanallc/grpc-server-reflection is unmaintained (last release 2022) and depends on google-protobuf ^3, which forced a scoped override to 4.0.3 for the unknown-group recursion DoS fix. The official @grpc/reflection uses protobufjs and does not depend on google-protobuf. The smoke test server already loads the proto through @grpc/proto-loader, so it builds the reflection service from that package definition and the pre-built route_guide.bin fixture is no longer needed. The scoped override is removed. Only the runtime grpc-reflection-js fork still needs the global google-protobuf override. Co-Authored-By: Claude Sonnet 5.5 * chore: update grpc-reflection-js to 0.4.0 and drop google-protobuf override (INS-3888) grpc-reflection-js 0.4.0 depends on google-protobuf ^4.0.3, which contains the fix for the unknown-group recursion DoS. The global google-protobuf override is no longer needed, so remove it and refresh the lockfile to the new fork commit. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 --- package-lock.json | 40 ++++++++++-------- .../fixtures/route_guide.bin | Bin 864 -> 0 bytes packages/insomnia-smoke-test/package.json | 2 +- packages/insomnia-smoke-test/server/grpc.ts | 7 +-- 4 files changed, 27 insertions(+), 22 deletions(-) delete mode 100644 packages/insomnia-smoke-test/fixtures/route_guide.bin diff --git a/package-lock.json b/package-lock.json index b5ab6652d2..eb61f3a8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2709,6 +2709,20 @@ "node": ">=6" } }, + "node_modules/@grpc/reflection": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@grpc/reflection/-/reflection-1.0.4.tgz", + "integrity": "sha512-znA8v4AviOD3OPOxy11pxrtP8k8DanpefeTymS8iGW1fVr1U2cHuzfhYqDPHnVNDf4qvF9E25KtSihPy2DBWfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "protobufjs": "^7.2.5" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.8.21" + } + }, "node_modules/@hapi/b64": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-5.0.0.tgz", @@ -4015,16 +4029,6 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@ravanallc/grpc-server-reflection": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@ravanallc/grpc-server-reflection/-/grpc-server-reflection-0.1.6.tgz", - "integrity": "sha512-jd2GIZZSv1faOcrVX22cBKdWuaneCHKlvV5vEIv1SA6Kxwt88m60qUiGo/oH8MLxSoWh7gnuGH+bNjWhPScYqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "google-protobuf": "^3.19.1" - } - }, "node_modules/@react-aria/autocomplete": { "version": "3.0.0-rc.6", "resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz", @@ -15331,9 +15335,9 @@ } }, "node_modules/google-protobuf": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", - "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-4.0.3.tgz", + "integrity": "sha512-DI6iiRmF8fE/EzvGK5F9b8RxZeumHC2AAhi3xwOEh+GT5eRhJeW1aOtkDxn0W7cVCFQAtwK+ZDwIO1BYz+9WMA==", "license": "(BSD-3-Clause AND Apache-2.0)" }, "node_modules/gopd": { @@ -15435,12 +15439,12 @@ } }, "node_modules/grpc-reflection-js": { - "version": "0.3.0", - "resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#f806b5a0dc2092b7a6fb54dfb66c38fb58231774", + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#e0b00b8b76f5d58135abadf70a23d6c2022f05ad", "license": "MIT", "dependencies": { - "@types/google-protobuf": "^3.7.2", - "google-protobuf": "^3.12.2", + "@types/google-protobuf": "^3.15.12", + "google-protobuf": "^4.0.3", "protobufjs": "^7.2.2" }, "peerDependencies": { @@ -26842,8 +26846,8 @@ "version": "13.3.1", "license": "Apache-2.0", "devDependencies": { + "@grpc/reflection": "^1.0.4", "@playwright/test": "1.59.1", - "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", "@types/uuid": "^9.0.8", diff --git a/packages/insomnia-smoke-test/fixtures/route_guide.bin b/packages/insomnia-smoke-test/fixtures/route_guide.bin deleted file mode 100644 index 489f4b3c5e8ba78eb4868b223a077f89ebd2d927..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 864 zcmZ`&%TB{E5G17qx-CK&Aw>lV2p2952qd_H0Eu1@DxvqvB-_*?iKEz&-~;#q{(%4D zU$C8|4`^@p?CkD%Muwj?xJs%2Mne>*ED=0T810IfzhPIRkf$T|F7r$kGoDKvhB*t+ zO*1Yz0RtJtxrIibvqX{(Y(!KtnFnZFht^5Mo7~AhG$WpbMX_dzQ^0wKriZ?yNf;qH zf?CAQfY$l8J6+_OEuJcB%9z=D%wo!zIghaZfK=-z1nLQm3r$VWoJm({<-StOljU>2swt$QSqvhQdrq8!Gff62$)cA`e?5fIaGJ$TpJ^r3JivlDNpLA-YT(yg3do{ z07g|$2Tm>ldMGO|{|D09na!x=IVUBVvYZkVOMi|;f}BNQ_W@3y8M_4S|V QTi>-+Z_s;PGhgK57x9k}+W-In diff --git a/packages/insomnia-smoke-test/package.json b/packages/insomnia-smoke-test/package.json index 82167144cc..0eefadd6d1 100644 --- a/packages/insomnia-smoke-test/package.json +++ b/packages/insomnia-smoke-test/package.json @@ -20,8 +20,8 @@ "serve": "esr server/index.ts" }, "devDependencies": { + "@grpc/reflection": "^1.0.4", "@playwright/test": "1.59.1", - "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", "@types/uuid": "^9.0.8", diff --git a/packages/insomnia-smoke-test/server/grpc.ts b/packages/insomnia-smoke-test/server/grpc.ts index e7a006d5b1..5e70da21bf 100644 --- a/packages/insomnia-smoke-test/server/grpc.ts +++ b/packages/insomnia-smoke-test/server/grpc.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import * as grpc from '@grpc/grpc-js'; import type { HandleCall } from '@grpc/grpc-js/build/src/server-call'; import * as protoLoader from '@grpc/proto-loader'; -import { addReflection } from '@ravanallc/grpc-server-reflection'; +import { ReflectionService } from '@grpc/reflection'; const PROTO_PATH = path.resolve('../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, { @@ -181,9 +181,10 @@ const routeChat: HandleCall = (call: any) => { export const startGRPCServer = (port: number) => { return new Promise((resolve, reject) => { const server = new grpc.Server(); + const reflectionService = new ReflectionService(packageDefinition); // Enable reflection - addReflection(server, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + reflectionService.addToServer(server); // @ts-expect-error generated from proto file server.addService(routeguide.RouteGuide.service, { @@ -211,7 +212,7 @@ export const startGRPCServer = (port: number) => { const serverWithTLS = new grpc.Server(); // Enable reflection - addReflection(serverWithTLS, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + reflectionService.addToServer(serverWithTLS); // @ts-expect-error generated from proto file serverWithTLS.addService(routeguide.RouteGuide.service, { From 1a5a33aaed8c637699cdc926fb3908f945525c69 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:31:11 +0800 Subject: [PATCH 2/7] fix: keep confidential flags through v5 export/import and Git Sync (#10632) * fix: keep confidential flags through v5 export/import and Git Sync Co-Authored-By: Claude Opus 5.5 * update schema * fix comment --------- Co-authored-by: Claude Opus 5.5 --- .../src/common/__tests__/insomnia-v5.test.ts | 119 +++++++++++++++++- .../insomnia/src/common/import-v5-parser.ts | 5 + packages/insomnia/src/common/insomnia-v5.ts | 72 ++++++++++- .../sync/git/__tests__/canonical-file.test.ts | 13 ++ schemas/insomnia.schema.5.1.json | 54 ++++++++ 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts index 2485f5898c..6b1a98ee34 100644 --- a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts +++ b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts @@ -6,7 +6,7 @@ */ import type { Request } from 'insomnia-data'; -import { EnvironmentKvPairDataType, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, EnvironmentType, services } from 'insomnia-data'; import { beforeEach, describe, expect, it } from 'vitest'; import YAML from 'yaml'; @@ -836,6 +836,123 @@ spec: }); }); + describe('confidentialKeys', () => { + const kvPair = (name: string, value: string, isConfidential: boolean, enabled = true) => ({ + id: `envPair_${name}_${value}`, + name, + value, + type: EnvironmentKvPairDataType.STRING, + enabled, + isConfidential, + }); + + it('exports the names of confidential variables for environments and folders', async () => { + const baseEnvironment = await services.environment.create({ + _id: 'env_base', + name: 'Base Env', + parentId: 'wrk_test', + data: { token: 'secret', host: 'example.com', apiKey: 'key', renamed: 'public' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [ + kvPair('token', 'secret', true), + kvPair('host', 'example.com', false), + kvPair('apiKey', 'key', true), + // only the last enabled row with a name takes effect + kvPair('renamed', 'old', true), + kvPair('renamed', 'public', false), + kvPair('disabled', 'value', true, false), + ], + }); + await services.environment.create({ + _id: 'env_sub', + name: 'Sub Env', + parentId: baseEnvironment._id, + data: { password: 'p@ss' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [kvPair('password', 'p@ss', true)], + }); + await services.environment.create({ + _id: 'env_json', + name: 'JSON Env', + parentId: baseEnvironment._id, + data: { stale: 'value' }, + // kvPairData is not the source of truth in the JSON view + environmentType: EnvironmentType.JSON, + kvPairData: [kvPair('stale', 'value', true)], + }); + await services.requestGroup.create({ + _id: 'fld_confidential', + name: 'Folder', + parentId: 'wrk_test', + environment: { folderToken: 'folder-secret' }, + environmentType: EnvironmentType.KVPAIR, + kvPairData: [kvPair('folderToken', 'folder-secret', true)], + }); + + const exported = YAML.parse( + await getInsomniaV5DataExport({ workspaceId: 'wrk_test', includePrivateEnvironments: false }), + ); + + expect(exported.environments.confidentialKeys).toEqual(['apiKey', 'token']); + const subEnvironments = Object.fromEntries(exported.environments.subEnvironments.map((e: any) => [e.name, e])); + expect(subEnvironments['Sub Env'].confidentialKeys).toEqual(['password']); + expect(subEnvironments['JSON Env']).not.toHaveProperty('confidentialKeys'); + expect(exported.collection[0].confidentialKeys).toEqual(['folderToken']); + }); + + it('restores the table view and confidential flags from confidentialKeys', () => { + const result = importInsomniaV5Data(` +type: collection.insomnia.rest/5.0 +name: Test Collection +meta: + id: wrk_test +collection: + - name: Folder + meta: + id: fld_confidential + environment: + folderToken: folder-secret + confidentialKeys: + - folderToken +environments: + name: Base Env + meta: + id: env_base + data: + token: secret + host: example.com + confidentialKeys: + - token + subEnvironments: + - name: Sub Env + meta: + id: env_sub + data: + password: p@ss + confidentialKeys: + - password + - name: Plain Env + meta: + id: env_plain + data: + foo: bar +`); + const byId = Object.fromEntries(result.map(doc => [doc._id, doc as any])); + const flags = (doc: any) => + Object.fromEntries(doc.kvPairData.map((pair: any) => [pair.name, pair.isConfidential])); + + expect(byId.env_base.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.env_base)).toEqual({ token: true, host: false }); + expect(byId.env_sub.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.env_sub)).toEqual({ password: true }); + expect(byId.fld_confidential.environmentType).toBe(EnvironmentType.KVPAIR); + expect(flags(byId.fld_confidential)).toEqual({ folderToken: true }); + // files without confidentialKeys import exactly as before + expect(byId.env_plain).not.toHaveProperty('environmentType'); + expect(byId.env_plain).not.toHaveProperty('kvPairData'); + }); + }); + describe('Edge Cases', () => { it('imports collection without meta', () => { const yaml = ` diff --git a/packages/insomnia/src/common/import-v5-parser.ts b/packages/insomnia/src/common/import-v5-parser.ts index 031171f9fc..40f7c31d8a 100644 --- a/packages/insomnia/src/common/import-v5-parser.ts +++ b/packages/insomnia/src/common/import-v5-parser.ts @@ -82,6 +82,8 @@ export const CookieJarSchema = z.object({ const { name, data, color, dataPropertyOrder, ...rest } = environment.baseEnvironmentSchema.omit({ metaSortKey: true, }).shape; +// Names of the confidential variables in `data`; see getConfidentialKeys in insomnia-v5.ts. +const confidentialKeys = z.array(z.string()).optional(); export const EnvironmentSchema = z.object({ name, meta: MetaSchema.optional(), @@ -96,11 +98,13 @@ export const EnvironmentSchema = z.object({ dataPropertyOrder, color, ...rest, + confidentialKeys, }), ) .optional(), dataPropertyOrder, ...rest, + confidentialKeys, }); const { @@ -201,6 +205,7 @@ export const RequestGroupSchema = z.object({ }) .optional(), ...restRequestGroupSchemaShape, + confidentialKeys, }); const { diff --git a/packages/insomnia/src/common/insomnia-v5.ts b/packages/insomnia/src/common/insomnia-v5.ts index 30316a9c70..4b74213e6a 100644 --- a/packages/insomnia/src/common/insomnia-v5.ts +++ b/packages/insomnia/src/common/insomnia-v5.ts @@ -35,13 +35,13 @@ import type { Workspace, WorkspaceScope, } from 'insomnia-data'; -import { EnvironmentKvPairDataType, models } from 'insomnia-data'; +import { EnvironmentKvPairDataType, EnvironmentType, models } from 'insomnia-data'; import { parse, stringify } from 'yaml'; import { type AllExportTypes, MODELS_BY_EXPORT_TYPE } from '~/common/import'; import { migrateToLatestYaml } from '~/common/insomnia-schema-migrations'; import { INSOMNIA_SCHEMA_VERSION } from '~/common/insomnia-schema-migrations/schema-version'; -import { maskVaultEnvironmentData } from '~/common/utils/environment-utils'; +import { getKVPairFromData, maskVaultEnvironmentData } from '~/common/utils/environment-utils'; import { invariant } from '~/common/utils/invariant'; import { database } from './database'; @@ -227,6 +227,56 @@ function filterEmptyValue(value: string | number | boolean | null | undefined) { return true; } +/** + * v5 files carry an environment's variables only as `data`, so its KV rows -- and the isConfidential + * flag that lives on them -- are rebuilt on import. `confidentialKeys` carries just the names of the + * confidential variables so the flag survives export/import, Duplicate / Move and Git Sync. + * + * Mirrors getDataFromKVPair: only the last enabled row with a given name takes effect. Sorted so the + * output is stable across import/export round-trips (canonical-file relies on that). + */ +function getConfidentialKeys( + environmentType: EnvironmentType | undefined, + kvPairData: EnvironmentKvPairData[] | undefined, +): string[] { + if (environmentType !== EnvironmentType.KVPAIR || !kvPairData) { + return []; + } + const isConfidentialByName = new Map(); + for (const pair of kvPairData) { + if (pair.enabled && pair.name) { + isConfidentialByName.set(pair.name, !!pair.isConfidential); + } + } + return [...isConfidentialByName] + .filter(([, isConfidential]) => isConfidential) + .map(([name]) => name) + .sort(); +} + +/** + * Inverse of getConfidentialKeys: rebuilds the KV rows from `data` and flags the confidential ones. + * Confidential flags only exist in the table view, so the environment is restored as KVPAIR. + * Without confidentialKeys nothing is added, which keeps the import unchanged for every other file. + */ +function getKvPairFieldsFromConfidentialKeys( + data: Record, + dataPropertyOrder: Record | null | undefined, + confidentialKeys: string[] | undefined, +): { environmentType?: EnvironmentType; kvPairData?: EnvironmentKvPairData[] } { + if (!confidentialKeys?.length) { + return {}; + } + const confidentialNames = new Set(confidentialKeys); + return { + environmentType: EnvironmentType.KVPAIR, + kvPairData: getKVPairFromData(data, dataPropertyOrder ?? null).map(pair => ({ + ...pair, + isConfidential: confidentialNames.has(pair.name), + })), + }; +} + /** * Recursively removes empty fields from an object or array * This is used to clean up export data by removing null, undefined, and empty objects @@ -327,6 +377,11 @@ function getEnvironments(file: InsomniaFile): Environment[] { color: file.environments.color || null, data: (file.environments.data as Record) || {}, dataPropertyOrder: (file.environments.dataPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (file.environments.data as Record) || {}, + file.environments.dataPropertyOrder, + file.environments.confidentialKeys, + ), name: file.environments.name || 'Base Environment', }; @@ -342,6 +397,11 @@ function getEnvironments(file: InsomniaFile): Environment[] { color: environment.color || null, data: (environment.data as Record) || {}, dataPropertyOrder: (environment.dataPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (environment.data as Record) || {}, + environment.dataPropertyOrder, + environment.confidentialKeys, + ), name: environment.name || `Environment ${index}`, parentId: baseEnvironment._id, })) || []; @@ -530,6 +590,11 @@ function getCollection( environment: (item.environment as Record) || {}, // 🚧 WARNING 🚧 If we set the order to an empty object instead of undefined it will remove the environment from the folder due to filtering logic (related to json-order) environmentPropertyOrder: (item.environmentPropertyOrder as Record) || undefined, + ...getKvPairFieldsFromConfidentialKeys( + (item.environment as Record) || {}, + item.environmentPropertyOrder, + item.confidentialKeys, + ), }; resources.push(requestGroup); @@ -918,6 +983,7 @@ export async function getInsomniaV5DataExport({ authentication: resource.authentication, environment: resource.environment, environmentPropertyOrder: resource.environmentPropertyOrder, + confidentialKeys: getConfidentialKeys(resource.environmentType, resource.kvPairData), headers: mapHeaders(resource.headers), }; collection.push(requestGroup); @@ -1023,6 +1089,7 @@ export async function getInsomniaV5DataExport({ isPrivate: baseEnvironment.isPrivate, }, data: baseEnvironment.data, + confidentialKeys: getConfidentialKeys(baseEnvironment.environmentType, baseEnvironment.kvPairData), color: baseEnvironment.color, subEnvironments: subEnvironments.map(maskVaultEnvironmentData).map(subEnvironment => ({ name: subEnvironment.name, @@ -1034,6 +1101,7 @@ export async function getInsomniaV5DataExport({ sortKey: subEnvironment.metaSortKey, }, data: subEnvironment.data, + confidentialKeys: getConfidentialKeys(subEnvironment.environmentType, subEnvironment.kvPairData), color: subEnvironment.color, })), }; diff --git a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts index c40ea82ea0..ce097f5125 100644 --- a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts @@ -179,6 +179,19 @@ routes: expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent); }); + it('keeps confidentialKeys through the round-trip so the flag survives Git Sync', async () => { + const content = `${buildCollection([{ id: 'fld_a', name: 'Folder A', created: 2000 }])} data: + token: secret + host: example.com + confidentialKeys: + - token +`; + const canonicalContent = await getCanonicalInsomniaFileContent(content); + expect(canonicalContent).toBe(await exportThroughDatabase(content)); + expect(YAML.parse(canonicalContent!).environments.confidentialKeys).toEqual(['token']); + expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent); + }); + it('does not read from, write to or notify the app database', async () => { await db.update({ _id: 'req_app', type: models.request.type, parentId: 'wrk_app' } as BaseModel); const appChanges: unknown[] = []; diff --git a/schemas/insomnia.schema.5.1.json b/schemas/insomnia.schema.5.1.json index 0b602bb4d3..1988d74ea4 100644 --- a/schemas/insomnia.schema.5.1.json +++ b/schemas/insomnia.schema.5.1.json @@ -310,6 +310,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -369,6 +375,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -892,6 +904,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -951,6 +969,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -1525,6 +1549,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -1584,6 +1614,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -2348,6 +2384,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -2407,6 +2449,12 @@ "json", "kv" ] + }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -5068,6 +5116,12 @@ } ] }, + "confidentialKeys": { + "type": "array", + "items": { + "type": "string" + } + }, "method": {}, "url": {}, "parameters": {}, From 59d832ba105e75d64950265445bf12225d8c68c0 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:30:58 +0800 Subject: [PATCH 3/7] Fix/socket io&websocket payload in export (#10634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: carry Socket.IO and WebSocket payloads through the v5 format [INS-3123] A Socket.IO request's body (event name, ack, args) lives in its own SocketIOPayload document rather than on the request, and the Insomnia v5 YAML format did not represent it at all. WebSocketPayload has the same shape and the same gap. Duplicating a collection goes through a v5 export/import round-trip, so the payload was silently dropped and the copy came back with an empty Body tab while url and headers survived. The same gap is worse under Git Sync projects: `deleteOrphans` removes every workspace descendant that is `canSync` and absent from the imported file, and the importer could never produce a payload. A `git pull`, a checkout, an edit to any part of the YAML, or merely restarting the app therefore deleted them. Editing a payload in the app never wrote to disk either, since the exported YAML did not change, so those edits only ever existed in the database until the next sync removed them. Both request nodes now carry an optional `body` holding the payload document's own id, so a re-import updates the same document instead of orphaning it. An untouched payload is omitted entirely: opening a Socket.IO request creates an empty payload document, and writing that out would add a spurious block and a git diff to every repository. The branches of `RequestCollectionSchema` are told apart only by the `meta.id` prefix, and `z.object` strips unknown keys rather than rejecting them, so a node without `meta` matched the WebSocket branch and lost its Socket.IO-only keys — `eventListeners` already went this way. Extend the existing `z.undefined()` guard idiom to cover them. Co-Authored-By: Claude Opus 5 * fix: export Socket.IO requests from design documents The design-scope export filter listed request groups, HTTP, WebSocket and gRPC requests but not Socket.IO ones, so a Socket.IO request created in a design document vanished from its YAML. Under a Git Sync project that is data loss rather than a missing field: the request is absent from the imported file, so `deleteOrphans` removes the request document itself on the next sync. Co-Authored-By: Claude Opus 5 * test(smoke): assert the Socket.IO body survives import The fixture had a Socket.IO request whose body the v5 format could not express, so the smoke test connected and sent nothing. Give it an event name and a payload, and assert both are populated after importing. Co-Authored-By: Claude Opus 5 * fix comment --------- Co-authored-by: Claude Opus 5 --- .../fixtures/socket-io.yaml | 11 + .../tests/smoke/socket-io.test.ts | 8 + .../common/__tests__/import-v5-parser.test.ts | 23 ++ .../__tests__/insomnia-v5-payload.test.ts | 315 ++++++++++++++++++ .../src/common/__tests__/insomnia-v5.test.ts | 60 ++++ .../insomnia/src/common/import-v5-parser.ts | 54 +++ packages/insomnia/src/common/insomnia-v5.ts | 175 +++++++++- .../repo-file-watcher-payload.test.ts | 139 ++++++++ schemas/insomnia.schema.5.1.json | 108 +++++- 9 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts create mode 100644 packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts diff --git a/packages/insomnia-smoke-test/fixtures/socket-io.yaml b/packages/insomnia-smoke-test/fixtures/socket-io.yaml index 3942acce98..1c561ed082 100644 --- a/packages/insomnia-smoke-test/fixtures/socket-io.yaml +++ b/packages/insomnia-smoke-test/fixtures/socket-io.yaml @@ -29,6 +29,17 @@ collection: eventName: eventname desc: this is a listener isOpen: false + body: + meta: + id: socket-io-payload_9a1c4f1b4f3b4d2e9c0a7d6e5f4b3a21 + created: 1753709130600 + modified: 1753757257862 + eventName: hello + ack: false + args: + - id: 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f + value: '"world"' + mode: application/json cookieJar: name: Default Jar meta: diff --git a/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts b/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts index 5b1b03a96a..dcdeaa85ed 100644 --- a/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/socket-io.test.ts @@ -20,6 +20,14 @@ test('can make socket.io connection', async ({ app, page, insomnia }) => { await insomnia.navigationSidebar.clickRequestOrFolder('Socket.IO Request'); await expect.soft(page.locator('.app')).toContainText('http://localhost:4020'); + + // The body lives in its own document, so it only survives the import if the v5 file carries it. + // Params is the initially selected tab and react-aria unmounts the panels it is not showing, + // so the Body tab has to be opened before any of it can be asserted. + await page.getByRole('tab', { name: 'Body' }).click(); + await expect.soft(page.getByLabel('Event Name')).toHaveValue('hello'); + await expect.soft(page.locator('[data-testid="CodeEditor"]:visible').first()).toContainText('world'); + await page.click('text=Connect'); await expect.soft(statusTag).toContainText('Connected', { ignoreCase: true }); await page.getByRole('tab', { name: 'Console' }).click(); diff --git a/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts b/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts index 48b1efcd37..6fb4df9df1 100644 --- a/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts +++ b/packages/insomnia/src/common/__tests__/import-v5-parser.test.ts @@ -570,6 +570,29 @@ describe('RequestCollectionSchema (recursive union)', () => { expect(parsed).toHaveLength(5); }); + // The union is not discriminated: without a `meta.id` prefix to go on, the only thing keeping + // a Socket.IO item out of the WebSocket branch (which would strip its Socket.IO-only keys) are + // the `z.undefined()` guards. + it('keeps eventListeners on a Socket.IO item that has no meta', () => { + const item = makeSocketIORequest({ meta: undefined }); + const [parsed] = RequestCollectionSchema.parse([item]); + expect(parsed).toMatchObject({ eventListeners: [{ id: 'ev1', eventName: 'join' }] }); + }); + + // `eventListeners` is dropped so the body guard is the only thing rejecting the WebSocket + // branch here — otherwise this would pass on the strength of the eventListeners guard alone + it('keeps the body of a Socket.IO item that has no meta', () => { + const item = makeSocketIORequest({ + meta: undefined, + eventListeners: undefined, + body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] }, + }); + const [parsed] = RequestCollectionSchema.parse([item]); + expect(parsed).toMatchObject({ + body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] }, + }); + }); + it('rejects leaf nodes with forbidden props (e.g., children on Request)', () => { const bad = structuredClone(makeHttpRequest()); // @ts-expect-error - children is not allowed on Request diff --git a/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts b/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts new file mode 100644 index 0000000000..808ac62e6f --- /dev/null +++ b/packages/insomnia/src/common/__tests__/insomnia-v5-payload.test.ts @@ -0,0 +1,315 @@ +/** + * A Socket.IO / WebSocket request's body lives in its own document (SocketIOPayload / + * WebSocketPayload) rather than on the request, and used to be absent from the v5 format + * entirely. These tests pin it to the format: INS-3123. + */ + +import type { Workspace } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; +import { beforeEach, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; + +import { database as db } from '../database'; +import { importResourcesToNewWorkspace } from '../import'; +import { getInsomniaV5DataExport, importInsomniaV5Data } from '../insomnia-v5'; + +const WORKSPACE_ID = 'wrk_payload'; +const SOCKET_IO_REQUEST_ID = 'socketio-req_payload'; +const WEBSOCKET_REQUEST_ID = 'ws-req_payload'; + +const exportWorkspace = (workspaceId = WORKSPACE_ID) => + getInsomniaV5DataExport({ workspaceId, includePrivateEnvironments: false }); + +const findNode = (yaml: string, name: string) => + YAML.parse(yaml).collection.find((item: { name: string }) => item.name === name); + +const createSocketIORequest = (patch: Record = {}) => + services.socketIORequest.create({ + _id: SOCKET_IO_REQUEST_ID, + name: 'socketio-request', + parentId: WORKSPACE_ID, + url: 'https://socket.example.com', + metaSortKey: 1, + ...patch, + }); + +// @vitest-environment jsdom +describe('Socket.IO / WebSocket payloads in the v5 format', () => { + beforeEach(async () => { + await db.init({ inMemoryOnly: true }); + await services.project.create({ _id: 'proj_payload', name: 'Payload Project' }); + await services.workspace.create({ + _id: WORKSPACE_ID, + name: 'Payload Workspace', + parentId: 'proj_payload', + scope: 'collection', + }); + await services.environment.getOrCreateForParentId(WORKSPACE_ID); + await services.settings.getOrCreate(); + }); + + describe('export', () => { + // Opening a Socket.IO request creates an empty payload document, which must not turn into a + // `body:` block — otherwise every repo gains a spurious git diff from merely browsing + it('omits body for a payload created by only opening the request', async () => { + await createSocketIORequest(); + await services.socketIOPayload.getOrCreateByParentId(SOCKET_IO_REQUEST_ID); + + expect(findNode(await exportWorkspace(), 'socketio-request').body).toBeUndefined(); + }); + + it('omits body for an untouched WebSocket payload', async () => { + await services.webSocketRequest.create({ + _id: WEBSOCKET_REQUEST_ID, + name: 'websocket-request', + parentId: WORKSPACE_ID, + url: 'wss://echo.example.com', + metaSortKey: 2, + }); + await services.webSocketPayload.create({ parentId: WEBSOCKET_REQUEST_ID }); + + expect(findNode(await exportWorkspace(), 'websocket-request').body).toBeUndefined(); + }); + + it('emits every arg once any of them has content', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [ + { id: 'arg1', value: '"world"', mode: 'application/json' }, + { id: 'arg2', value: '', mode: 'text/plain' }, + ], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toEqual([ + { id: 'arg1', value: '"world"', mode: 'application/json' }, + { id: 'arg2', value: '', mode: 'text/plain' }, + ]); + }); + + // Arg rows and content types are configuration in their own right. Calling these untouched + // would omit the body, and the next sync's `deleteOrphans` would then delete the payload. + it('emits a body for an arg the user added but left blank', async () => { + await createSocketIORequest(); + const { args } = models.socketIOPayload.init(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [...args, { id: 'arg2', value: '', mode: args[0].mode }], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toHaveLength(2); + }); + + it('emits a body when only an arg content type changed', async () => { + await createSocketIORequest(); + const { args } = models.socketIOPayload.init(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + args: [{ ...args[0], mode: 'text/plain' }], + }); + + expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toMatchObject([ + { value: '', mode: 'text/plain' }, + ]); + }); + }); + + describe('import', () => { + it('recreates the payload as a child of its request', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + _id: 'socket-io-payload_known', + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + + const resources = importInsomniaV5Data(await exportWorkspace()); + const payload = resources.find(resource => resource.type === 'SocketIOPayload'); + + expect(payload).toMatchObject({ + _id: 'socket-io-payload_known', + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + expect(payload).toHaveProperty('_type', 'socketio_payload'); + }); + + // `importResourcesToNewWorkspace` keys its id map by `_id`, so payloads sharing one literal + // placeholder would collapse into a single document. Hand-written YAML may well omit + // `body.meta`, which is what puts them on that placeholder path. + it('gives bodies without a meta id distinct payload ids', async () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: No Body Meta +meta: + id: wrk_no_body_meta +collection: + - url: https://socket.example.com + name: First + meta: + id: socketio-req_first + body: + eventName: first + args: + - id: arg1 + value: '"a"' + - url: https://socket.example.com + name: Second + meta: + id: socketio-req_second + body: + eventName: second + args: + - id: arg2 + value: '"b"' +`; + + const payloads = importInsomniaV5Data(yaml).filter(resource => resource.type === 'SocketIOPayload'); + + expect(payloads).toHaveLength(2); + expect(payloads[0]._id).not.toBe(payloads[1]._id); + expect(payloads[0].parentId).not.toBe(payloads[1].parentId); + }); + + // Git Sync writes imported documents to the database verbatim, so a generated payload id has + // to survive the export that validates it. A non-conforming one throws inside + // `InsomniaFileSchema.parse`, which empties the export — and an empty export is skipped by + // every flush site, silently stopping db-to-fs sync for the whole workspace. + it('re-exports a body that arrived without a body.meta', async () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Payload Workspace +meta: + id: ${WORKSPACE_ID} +collection: + - url: https://socket.example.com + name: socketio-request + meta: + id: ${SOCKET_IO_REQUEST_ID} + body: + eventName: chat message + args: + - id: arg1 + value: '"world"' + - url: wss://echo.example.com + name: websocket-request + meta: + id: ${WEBSOCKET_REQUEST_ID} + body: + value: '{"ping":1}' +`; + + for (const doc of importInsomniaV5Data(yaml)) { + await db.update(doc); + } + + const reExported = await exportWorkspace(); + + expect(reExported).not.toBe(''); + expect(findNode(reExported, 'socketio-request').body).toMatchObject({ eventName: 'chat message' }); + expect(findNode(reExported, 'websocket-request').body).toMatchObject({ value: '{"ping":1}' }); + }); + + // The union guards keep the two realtime types apart inside `RequestCollectionSchema`, but + // `walkCollection` routes with the base schemas, so this has to be asserted end to end + it('keeps a Socket.IO item without meta on the Socket.IO branch', () => { + const yaml = `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: No Meta +meta: + id: wrk_no_meta +collection: + - url: https://socket.example.com + name: Rooms + eventListeners: + - id: ev1 + eventName: joined + body: + eventName: chat message + ack: true + args: + - id: arg1 + value: '"world"' +`; + + const resources = importInsomniaV5Data(yaml); + + expect(resources.map(resource => resource.type)).toEqual( + expect.arrayContaining(['SocketIORequest', 'SocketIOPayload']), + ); + expect(resources.map(resource => resource.type)).not.toContain('WebSocketPayload'); + expect(resources.find(resource => resource.type === 'SocketIOPayload')).toMatchObject({ + eventName: 'chat message', + ack: true, + }); + }); + }); + + // The three calls the Duplicate Collection action makes, in order + it('preserves payloads when a collection is duplicated (INS-3123)', async () => { + await createSocketIORequest(); + await services.socketIOPayload.create({ + parentId: SOCKET_IO_REQUEST_ID, + eventName: 'hello', + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + await services.webSocketRequest.create({ + _id: WEBSOCKET_REQUEST_ID, + name: 'websocket-request', + parentId: WORKSPACE_ID, + url: 'wss://echo.example.com', + metaSortKey: 2, + }); + await services.webSocketPayload.create({ + parentId: WEBSOCKET_REQUEST_ID, + value: '{"ping":1}', + mode: 'application/json', + }); + // An untouched request, which must stay empty in the copy + await services.socketIORequest.create({ + _id: 'socketio-req_untouched', + name: 'untouched', + parentId: WORKSPACE_ID, + url: 'https://socket.example.com', + metaSortKey: 3, + }); + await services.socketIOPayload.getOrCreateByParentId('socketio-req_untouched'); + + const oldWorkspace = (await services.workspace.getById(WORKSPACE_ID)) as Workspace; + const content = await exportWorkspace(); + const data = importInsomniaV5Data(content); + + const newWorkspace = await importResourcesToNewWorkspace({ + projectId: 'proj_payload', + workspaceToImport: { ...oldWorkspace, name: 'Payload Workspace (Copy)' }, + resourceCacheItem: { + resources: data, + content: JSON.stringify(data), + importer: { id: 'insomnia-v5', name: 'Insomnia v5 Importer', description: '' }, + }, + }); + + const copiedRequests = await services.socketIORequest.findByParentId(newWorkspace._id); + const copiedSocketIO = copiedRequests.find(request => request.name === 'socketio-request'); + const [copiedWebSocket] = await services.webSocketRequest.findByParentId(newWorkspace._id); + + expect(newWorkspace._id).not.toBe(WORKSPACE_ID); + expect(copiedSocketIO?._id).not.toBe(SOCKET_IO_REQUEST_ID); + + await expect(services.socketIOPayload.getByParentId(copiedSocketIO!._id)).resolves.toMatchObject({ + eventName: 'hello', + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + await expect(services.webSocketPayload.getByParentId(copiedWebSocket._id)).resolves.toMatchObject({ + value: '{"ping":1}', + mode: 'application/json', + }); + + const copiedUntouched = copiedRequests.find(request => request.name === 'untouched'); + await expect(services.socketIOPayload.getByParentId(copiedUntouched!._id)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts index 6b1a98ee34..0e72939487 100644 --- a/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts +++ b/packages/insomnia/src/common/__tests__/insomnia-v5.test.ts @@ -400,6 +400,43 @@ spec: expect(parsed.spec).toBeDefined(); }); + // A Socket.IO request used to be dropped from a design doc's export, which in a Git Sync + // project meant `deleteOrphans` removed the request itself on the next sync + it('exports Socket.IO requests from a design workspace', async () => { + const workspace = await services.workspace.create({ + _id: 'wrk_design_socketio', + name: 'Design Workspace', + parentId: 'proj_test', + scope: 'design', + }); + await services.environment.create({ + _id: 'env_design_socketio', + name: 'Base Env', + parentId: workspace._id, + data: {}, + }); + await services.apiSpec.getOrCreateForParentId(workspace._id, { + _id: 'spec_design_socketio', + contents: '{"openapi": "3.0.0"}', + contentType: 'json', + }); + await services.socketIORequest.create({ + _id: 'socketio-req_design', + name: 'design-socketio', + parentId: workspace._id, + url: 'https://socket.example.com', + metaSortKey: 1, + }); + + const result = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + + const parsed = YAML.parse(result); + expect(parsed.collection.map((item: { name: string }) => item.name)).toContain('design-socketio'); + }); + it('handles mock server scope', async () => { const workspace = await services.workspace.create({ _id: 'wrk_mock', @@ -693,6 +730,20 @@ spec: eventListeners: [{ id: 'listener1', eventName: 'join', desc: 'on join', isOpen: true }], metaSortKey: 5, }); + await services.webSocketPayload.create({ + _id: 'ws-payload_order', + parentId: 'ws-req_order', + name: 'payload-order', + value: '{"ping":1}', + mode: 'application/json', + }); + await services.socketIOPayload.create({ + _id: 'socket-io-payload_order', + parentId: 'socketio-req_order', + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); const cookieJar = await services.cookieJar.getOrCreateForParentId(workspace._id); await services.cookieJar.update(cookieJar, { @@ -795,9 +846,13 @@ spec: 'headers', 'parameters', 'pathParameters', + 'body', ]); expect(Object.keys(exportedWebSocket.settings)).toEqual(['encodeUrl', 'cookies', 'followRedirects']); expect(Object.keys(exportedWebSocket.settings.cookies)).toEqual(['store', 'send']); + expect(Object.keys(exportedWebSocket.body)).toEqual(['meta', 'name', 'value', 'mode']); + expect(Object.keys(exportedWebSocket.body.meta)).toEqual(['id', 'created', 'modified']); + expect(exportedWebSocket.body.meta.id).toBe('ws-payload_order'); const exportedSocketIO = findItem('socketio-order'); expect(Object.keys(exportedSocketIO)).toEqual([ @@ -810,10 +865,15 @@ spec: 'parameters', 'pathParameters', 'eventListeners', + 'body', ]); expect(Object.keys(exportedSocketIO.settings)).toEqual(['encodeUrl', 'cookies', 'path']); expect(Object.keys(exportedSocketIO.settings.cookies)).toEqual(['store', 'send']); expect(Object.keys(exportedSocketIO.eventListeners[0])).toEqual(['id', 'eventName', 'desc', 'isOpen']); + expect(Object.keys(exportedSocketIO.body)).toEqual(['meta', 'eventName', 'ack', 'args']); + expect(Object.keys(exportedSocketIO.body.meta)).toEqual(['id', 'created', 'modified']); + expect(exportedSocketIO.body.meta.id).toBe('socket-io-payload_order'); + expect(Object.keys(exportedSocketIO.body.args[0])).toEqual(['id', 'value', 'mode']); expect(Object.keys(parsed.cookieJar)).toEqual(['name', 'meta', 'cookies']); expect(Object.keys(parsed.cookieJar.cookies[0])).toEqual([ diff --git a/packages/insomnia/src/common/import-v5-parser.ts b/packages/insomnia/src/common/import-v5-parser.ts index 40f7c31d8a..9828ba8b71 100644 --- a/packages/insomnia/src/common/import-v5-parser.ts +++ b/packages/insomnia/src/common/import-v5-parser.ts @@ -244,6 +244,39 @@ export const RequestSchema = z.object({ pathParameters: requestPathParametersShape.nullable(), }); +/** + * A WebSocket/Socket.IO request's body lives in its own document (WebSocketPayload / + * SocketIOPayload), not on the request itself. `meta.id` carries that document's real id so + * that re-importing a file updates the same document instead of orphaning it and creating a + * new one — which is what git sync does on every pull. + */ +export const WebsocketRequestBodySchema = z.object({ + meta: MetaSchema.extend({ + id: z.string().startsWith('ws-payload'), + }).optional(), + name: z.string().optional().default(''), + value: z.string().optional().default(''), + mode: z.string().optional().default('application/json'), +}); + +export const SocketIORequestBodySchema = z.object({ + meta: MetaSchema.extend({ + id: z.string().startsWith('socket-io-payload'), + }).optional(), + eventName: z.string().optional().default(''), + ack: z.boolean().optional().default(false), + args: z + .array( + z.object({ + id: z.string(), + value: z.string().optional().default(''), + mode: z.string().optional().default('application/json'), + }), + ) + .optional() + .default([]), +}); + const { url: webSocketRequestUrlShape, name: webSocketRequestNameShape, @@ -269,6 +302,7 @@ export const WebsocketRequestSchema = z.object({ }), ...restWebSocketRequestSchemaShape, pathParameters: webSocketRequestPathParametersShape.nullable(), + body: WebsocketRequestBodySchema.optional(), }); const { @@ -293,6 +327,7 @@ export const SocketIORequestSchema = z.object({ path: undefined, }), ...restSocketIORequestSchemaShape, + body: SocketIORequestBodySchema.optional(), }); // Keep the key order consistent so that export to YAML maintains the same order as before: meta sits after authentication @@ -333,6 +368,12 @@ const RequestGroupWithChildrenSchema: z.ZodType = RequestGroupSche pathParameters: z.undefined(), }); +/** + * NOTE: this is a plain union, not a discriminated one, so branch order and the `z.undefined()` + * guards below are what keep the request types apart. Any key unique to the WebSocket or the + * Socket.IO branch must also be added as `z.undefined()` to the other one, otherwise an item + * without a `meta.id` prefix to discriminate on matches the wrong branch and loses that key. + */ export const RequestCollectionSchema = z .union([ GRPCRequestSchema.extend({ @@ -348,11 +389,24 @@ export const RequestCollectionSchema = z // These undefined properties are added to differentiate between the different types of children in the union children: z.undefined(), method: z.undefined(), + // Socket.IO-only keys. Without these guards a Socket.IO item that has no `meta` matches + // this branch and silently loses them, because z.object strips unknown keys. + eventListeners: z.undefined(), + body: WebsocketRequestBodySchema.extend({ + eventName: z.undefined(), + ack: z.undefined(), + args: z.undefined(), + }).optional(), }), SocketIORequestSchema.extend({ // These undefined properties are added to differentiate between the different types of children in the union children: z.undefined(), method: z.undefined(), + body: SocketIORequestBodySchema.extend({ + name: z.undefined(), + value: z.undefined(), + mode: z.undefined(), + }).optional(), }), RequestGroupWithChildrenSchema, ]) diff --git a/packages/insomnia/src/common/insomnia-v5.ts b/packages/insomnia/src/common/insomnia-v5.ts index 4b74213e6a..fe9acf5b49 100644 --- a/packages/insomnia/src/common/insomnia-v5.ts +++ b/packages/insomnia/src/common/insomnia-v5.ts @@ -28,9 +28,11 @@ import type { RequestGroup, RequestHeader, RequestParameter, + SocketIOPayload, SocketIORequest, UnitTest, UnitTestSuite, + WebSocketPayload, WebSocketRequest, Workspace, WorkspaceScope, @@ -133,6 +135,81 @@ function mapMeta(resource: Request | WebSocketRequest | SocketIORequest | GrpcRe }; } +/** + * Maps a payload document's metadata to v5 export format. + * + * Deliberately narrower than `mapMeta`: a payload has no description and no sort order, and + * `filterEmptyValue` keeps `""`, `false` and `0`, so reusing `mapMeta` would add three + * meaningless lines to every exported payload. + */ +function mapPayloadMeta(payload: SocketIOPayload | WebSocketPayload) { + return { + id: payload._id, + created: payload.created, + modified: payload.modified, + }; +} + +/** + * Maps a Socket.IO request's payload document to the v5 `body` field. + * + * Merely opening a Socket.IO request creates an empty payload document (via + * `services.socketIOPayload.getOrCreateByParentId`), so an untouched payload is omitted + * entirely — otherwise every repo would gain a spurious `body:` block and a git diff. This + * cannot be delegated to `removeEmptyFields`, which preserves `""` and `false`. + */ +function mapSocketIOBody(payload?: SocketIOPayload): Insomnia_SocketIORequest['body'] { + if (!payload) { + return; + } + + // Compared against the model's own defaults rather than just checking for empty values: an arg + // the user added but left blank, or whose content type they changed, is real configuration. + // Calling it untouched would drop it from the file and let `deleteOrphans` delete the payload + // on the next sync. `init()` cannot be compared wholesale — it mints a fresh arg id each call. + const defaults = models.socketIOPayload.init(); + const args = payload.args ?? []; + const isUntouched = + !payload.eventName && + !payload.ack && + args.length === defaults.args.length && + args.every((arg, index) => !arg.value && arg.mode === defaults.args[index].mode); + + if (isUntouched) { + return; + } + + return { + meta: mapPayloadMeta(payload), + eventName: payload.eventName ?? '', + ack: payload.ack ?? false, + // Emit every arg once any of them has content, so arg ids, order and per-arg mode survive + args, + }; +} + +/** + * Maps a WebSocket request's payload document to the v5 `body` field. See `mapSocketIOBody` + * for why an untouched payload is omitted. + */ +function mapWebSocketBody(payload?: WebSocketPayload): Insomnia_WebsocketRequest['body'] { + if (!payload) { + return; + } + + const defaults = models.webSocketPayload.init(); + if (payload.name === defaults.name && payload.value === defaults.value && payload.mode === defaults.mode) { + return; + } + + return { + meta: mapPayloadMeta(payload), + name: payload.name, + value: payload.value, + mode: payload.mode, + }; +} + /** * Maps metadata from RequestGroup to v5 export format * Similar to mapMeta but specifically for request groups @@ -558,11 +635,38 @@ function getTestSuites(file: InsomniaFile): (UnitTestSuite | UnitTest)[] { return []; } +/** + * Tells a Socket.IO collection node apart from a WebSocket one. + * + * `WebsocketRequestSchema.safeParse` cannot do this on its own: once `metaSortKey` is + * destructured out neither schema has a required field, and `z.object` strips unknown keys, so a + * Socket.IO item parses cleanly as a WebSocket request and silently loses its `eventListeners` + * and `body`. The `z.undefined()` guards on `RequestCollectionSchema` keep the two apart inside + * the union, but `walkCollection` routes with the base schemas, so it needs its own check. + * + * `meta.id` is authoritative when present — every Insomnia-generated file has it. Otherwise fall + * back to keys only one of the two can carry. An item with none of them is genuinely + * indistinguishable and stays on the WebSocket branch, as it did before. + */ +function isSocketIOCollectionNode(item: Record): boolean { + if (models.socketIORequest.isSocketIORequestId(item.meta?.id)) { + return true; + } + if (models.webSocketRequest.isWebSocketRequestId(item.meta?.id)) { + return false; + } + if (item.eventListeners || item.settings?.path !== undefined) { + return true; + } + const body = item.body; + return Boolean(body && ('eventName' in body || 'ack' in body || 'args' in body)); +} + function getCollection( file: InsomniaFile, -): (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] { +): (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] { if (file.type === 'collection.insomnia.rest/5.0' || file.type === 'spec.insomnia.rest/5.0') { - const resources: (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] = []; + const resources: (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] = []; function walkCollection( collection: Extract['collection'], @@ -663,7 +767,7 @@ function getCollection( resources.push(grpcRequest); } else { const wbRequest = WebsocketRequestSchema.safeParse(item); - if (wbRequest.success) { + if (wbRequest.success && !isSocketIOCollectionNode(item)) { const data = wbRequest.data; const websocketRequest: WithExportType = { ...mapMetaToInsomniaMeta( @@ -689,6 +793,34 @@ function getCollection( }; resources.push(websocketRequest); + + if (data.body) { + const webSocketPayload: WithExportType = { + // Derived from the owning request's id rather than a shared literal placeholder: + // `importResourcesToNewWorkspace` keys its id map by `_id`, so a shared one would + // collapse every payload in the file into a single document. + // + // It must also carry the model's own prefix. Git Sync persists this `_id` + // verbatim, and the next export validates it against `WebsocketRequestBodySchema` + // — a non-conforming id throws there, which empties the export and silently stops + // db-to-fs sync for the whole workspace. + ...mapMetaToInsomniaMeta( + data.body.meta || { + id: `${models.webSocketPayload.prefix}_${websocketRequest._id}`, + }, + ), + type: 'WebSocketPayload', + _type: 'websocket_payload', + // Must be the request's id as it appears in this same flat array, so that the + // import's second pass can resolve it through the id map + parentId: websocketRequest._id, + name: data.body.name, + value: data.body.value, + mode: data.body.mode, + }; + + resources.push(webSocketPayload); + } } else { const socketIORequest = SocketIORequestSchema.safeParse(item); if (socketIORequest.success) { @@ -718,6 +850,28 @@ function getCollection( }; resources.push(socketIO); + + if (data.body) { + const socketIOPayload: WithExportType = { + // See the WebSocket payload above for why the id is prefixed and derived from + // the owning request + ...mapMetaToInsomniaMeta( + data.body.meta || { + id: `${models.socketIOPayload.prefix}_${socketIO._id}`, + }, + ), + type: 'SocketIOPayload', + _type: 'socketio_payload', + parentId: socketIO._id, + // A Socket.IO payload has no user-facing name; '' is the base model's default + name: '', + eventName: data.body.eventName, + ack: data.body.ack, + args: data.body.args, + }; + + resources.push(socketIOPayload); + } } } } @@ -921,6 +1075,16 @@ export async function getInsomniaV5DataExport({ return false; }); + // Payload documents are children of their request rather than collection nodes of their own, + // so they are looked up by parentId while building the owning request's node, and are never + // passed to `getCollectionFromResources`. + const socketIOPayloadsByRequestId = new Map( + exportableResources.filter(models.socketIOPayload.isSocketIOPayload).map(payload => [payload.parentId, payload]), + ); + const webSocketPayloadsByRequestId = new Map( + exportableResources.filter(models.webSocketPayload.isWebSocketPayload).map(payload => [payload.parentId, payload]), + ); + /** * Recursively builds a collection structure from flat resource list * This function converts the flat list of resources into a hierarchical structure @@ -1006,6 +1170,7 @@ export async function getInsomniaV5DataExport({ headers: mapHeaders(resource.headers), parameters: mapParameters(resource.parameters), pathParameters: resource.pathParameters, + body: mapWebSocketBody(webSocketPayloadsByRequestId.get(resource._id)), }; collection.push(webSocketRequest); } else if (models.socketIORequest.isSocketIORequest(resource)) { @@ -1026,6 +1191,7 @@ export async function getInsomniaV5DataExport({ parameters: mapParameters(resource.parameters), pathParameters: resource.pathParameters, eventListeners: resource.eventListeners, + body: mapSocketIOBody(socketIOPayloadsByRequestId.get(resource._id)), }; collection.push(socketIORequest); } else if (models.grpcRequest.isGrpcRequest(resource)) { @@ -1305,7 +1471,8 @@ export async function getInsomniaV5DataExport({ models.requestGroup.isRequestGroup(resource) || models.request.isRequest(resource) || models.webSocketRequest.isWebSocketRequest(resource) || - models.grpcRequest.isGrpcRequest(resource), + models.grpcRequest.isGrpcRequest(resource) || + models.socketIORequest.isSocketIORequest(resource), ), workspace._id, ), diff --git a/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts new file mode 100644 index 0000000000..b9d5dd4a91 --- /dev/null +++ b/packages/insomnia/src/sync/git/__tests__/repo-file-watcher-payload.test.ts @@ -0,0 +1,139 @@ +/** + * Socket.IO / WebSocket payloads through the Git Sync project fs→db path. + * + * Lives in its own file because `repo-file-watcher.test.ts` module-mocks `common/insomnia-v5`, + * and these tests need the real import/export. + * + * Before the v5 format carried the payload, `deleteOrphans` removed every payload document on + * each real fs→db sync — a `git pull`, a `checkout`, an edit to any part of the YAML, or just + * restarting the app — because the importer could never produce one. See INS-3123. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { services } from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { database as db } from '../../../common/database'; +import { RepoFileWatcherRegistry } from '../repo-file-watcher'; + +const REPO_ID = 'git_repo_payload'; +const PROJECT_ID = 'proj_payload'; +const WORKSPACE_ID = 'wrk_payload'; +const REQUEST_ID = 'socketio-req_payload'; +const PAYLOAD_ID = 'socket-io-payload_payload'; +const YAML_PATH = 'insomnia.wrk_payload.yaml'; + +const collectionYaml = ({ withBody = true, withRequest = true, requestName = 'Rooms' } = {}) => `type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Payload Collection +meta: + id: ${WORKSPACE_ID} + created: 1000 + modified: 1000 +collection:${ + withRequest + ? ` + - url: https://socket.example.com + name: ${requestName} + meta: + id: ${REQUEST_ID} + created: 1000 + modified: 1000 + sortKey: 1${ + withBody + ? ` + body: + meta: + id: ${PAYLOAD_ID} + created: 1000 + modified: 1000 + eventName: chat message + ack: true + args: + - id: arg1 + value: '"world"' + mode: application/json` + : '' + }` + : ' []' +} +environments: + name: Base Environment + meta: + id: env_payload + created: 1000 + modified: 1000 + isPrivate: false +`; + +describe('RepoFileWatcher keeps Socket.IO payloads through fs→db syncs', () => { + let repoDir: string; + let registry: RepoFileWatcherRegistry; + let absPath: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + await services.project.create({ _id: PROJECT_ID, name: 'Payload Project' }); + repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-repo-watcher-payload-')); + absPath = path.join(repoDir, YAML_PATH); + registry = new RepoFileWatcherRegistry({ onDbSynced: vi.fn(), onProblemsChanged: vi.fn() }); + }); + + afterEach(async () => { + registry.stopAll(); + await fs.promises.rm(repoDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('imports a body into a real payload document keyed by the id in the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({ + _id: PAYLOAD_ID, + eventName: 'chat message', + ack: true, + args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }], + }); + }); + + // Re-importing the file is what a `git pull` or `checkout` triggers — the exact moment the + // payload used to be deleted as an orphan. + // + // The edit has to change the file: `readIfChanged` dedups on a content hash, so re-importing + // byte-identical content returns early and `deleteOrphans` never runs at all. + it('keeps the payload when an unrelated edit re-imports the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + + await fs.promises.writeFile(absPath, collectionYaml({ requestName: 'Rooms renamed' }), 'utf8'); + await registry.importAllFiles(REPO_ID); + await registry.flushNow(REPO_ID); + + // Proves the import actually ran, so the payload assertion below is not vacuous + await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toMatchObject({ + name: 'Rooms renamed', + }); + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({ + _id: PAYLOAD_ID, + eventName: 'chat message', + }); + }); + + it('deletes the payload along with its request when the request leaves the file', async () => { + await fs.promises.writeFile(absPath, collectionYaml(), 'utf8'); + await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID); + await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toBeDefined(); + + await fs.promises.writeFile(absPath, collectionYaml({ withRequest: false }), 'utf8'); + await registry.importAllFiles(REPO_ID); + await registry.flushNow(REPO_ID); + + await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toBeUndefined(); + await expect(services.socketIOPayload.getById(PAYLOAD_ID)).resolves.toBeUndefined(); + }); +}); diff --git a/schemas/insomnia.schema.5.1.json b/schemas/insomnia.schema.5.1.json index 1988d74ea4..2f43398414 100644 --- a/schemas/insomnia.schema.5.1.json +++ b/schemas/insomnia.schema.5.1.json @@ -3914,8 +3914,53 @@ } ] }, + "body": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^ws-payload.*" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "eventName": {}, + "ack": {}, + "args": {} + } + }, "children": {}, - "method": {} + "method": {}, + "eventListeners": {} } }, { @@ -4524,6 +4569,67 @@ "disableUserAgentHeader": { "type": "boolean" }, + "body": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^socket-io-payload.*" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "eventName": { + "type": "string" + }, + "ack": { + "type": "boolean" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "name": {}, + "value": {}, + "mode": {} + } + }, "children": {}, "method": {} } From 7498c30b57e4aa17c00a3bf11ac55a93b29c3d9b Mon Sep 17 00:00:00 2001 From: George Date: Thu, 22 Oct 2026 15:49:51 +0800 Subject: [PATCH 4/7] fix(sidebar): scope the del shortcut without backspace (#10644) * fix: scope the del shortcut without backspace * test: add e2e --- packages/insomnia-data/common-src/hotkeys.ts | 5 -- packages/insomnia-data/common-src/settings.ts | 1 - .../tests/smoke/focus-and-keyboard.test.ts | 32 ++++++++++++ .../project-navigation-sidebar.tsx | 52 +++++++++---------- 4 files changed, 56 insertions(+), 34 deletions(-) diff --git a/packages/insomnia-data/common-src/hotkeys.ts b/packages/insomnia-data/common-src/hotkeys.ts index 2f69718f37..3e764cd117 100644 --- a/packages/insomnia-data/common-src/hotkeys.ts +++ b/packages/insomnia-data/common-src/hotkeys.ts @@ -26,7 +26,6 @@ export const keyboardShortcutDescriptions: Record = { request_showGenerateCodeEditor: 'Generate Code', sidebar_focusFilter: 'Filter Sidebar', sidebar_showCreateDropdown: 'Open Create Dropdown', - sidebar_deleteItem: 'Delete Sidebar Item', sidebar_toggle: 'Toggle Sidebar', sidebar_rename: 'Rename Item', response_focus: 'Focus Response', @@ -130,10 +129,6 @@ const defaultRegistry: HotKeyRegistry = { macKeys: [{ meta: true, keyCode: keyboardKeys.n.keyCode }], winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.n.keyCode }], }, - sidebar_deleteItem: { - macKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }], - winLinuxKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }], - }, sidebar_toggle: { macKeys: [{ meta: true, keyCode: keyboardKeys.backslash.keyCode }], winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.backslash.keyCode }], diff --git a/packages/insomnia-data/common-src/settings.ts b/packages/insomnia-data/common-src/settings.ts index cccc548251..d6cb55c83d 100644 --- a/packages/insomnia-data/common-src/settings.ts +++ b/packages/insomnia-data/common-src/settings.ts @@ -47,7 +47,6 @@ export type KeyboardShortcut = | 'request_showGenerateCodeEditor' | 'sidebar_focusFilter' | 'sidebar_showCreateDropdown' - | 'sidebar_deleteItem' | 'sidebar_toggle' | 'sidebar_rename' | 'response_focus' diff --git a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts index eeb6152922..8120641a41 100644 --- a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts @@ -10,6 +10,7 @@ import { test } from '../../playwright/test'; // - create/rename/settings dialogs focus the Name field // - the navigation sidebar expands/collapses folders with Left/Right arrows // - Cmd/Ctrl-N adds the new request inside the selected folder +// - Delete on a focused sidebar row asks to delete it; Backspace never does // // CodeMirror-backed editors (OneLineEditor) expose focus via `data-focused="on"` on their // `.editor__container`, which is what these tests assert against. @@ -150,6 +151,37 @@ test.describe('Focus and keyboard navigation', () => { await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible(); }); + test('Delete on a focused sidebar row opens the delete confirmation, Backspace does not', async ({ page }) => { + const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])'); + const deleteDialog = page.getByRole('dialog').filter({ hasText: 'Delete Folder' }); + + // Backspace must not delete (it's reserved for editing text), even with a row focused. + await folderRow.focus(); + await folderRow.press('Backspace'); + await expect.soft(deleteDialog).toBeHidden(); + + // Delete on the focused row asks for confirmation; cancelling keeps the folder. + await folderRow.press('Delete'); + await expect.soft(deleteDialog).toBeVisible(); + await deleteDialog.getByRole('button', { name: 'Cancel' }).click(); + await expect.soft(deleteDialog).toBeHidden(); + await expect.soft(page.getByTestId('request-node-test folder')).toBeVisible(); + + // Confirming removes it. + await folderRow.press('Delete'); + await deleteDialog.getByRole('button', { name: 'Delete' }).click(); + await expect.soft(page.getByTestId('request-node-test folder')).toBeHidden(); + }); + + test('Delete inside the sidebar filter edits text and does not open the delete confirmation', async ({ + page, + insomnia, + }) => { + await insomnia.navigationSidebar.fillFilter('test'); + await insomnia.navigationSidebar.filterInput.press('Delete'); + await expect.soft(page.getByRole('dialog')).toBeHidden(); + }); + test('Cmd/Ctrl-N adds the request inside the selected folder', async ({ page }) => { const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])'); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 1e17e13885..559835ec7b 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -837,33 +837,6 @@ const ProjectNavigationSidebarInner = ( }); }); }, - sidebar_deleteItem: event => { - // Only act when a row itself is focused, not an inner control (button/input), so Backspace keeps editing text. - const active = document.activeElement; - if (!(active instanceof HTMLElement) || active.closest('[data-key]') !== active) { - return; - } - const { focusedItem } = getFocusedItem(); - if ( - !focusedItem || - (focusedItem.kind !== 'project' && - focusedItem.kind !== 'workspace' && - focusedItem.kind !== 'collectionChild' && - focusedItem.kind !== 'pinnedRequest') - ) { - return; - } - - event.preventDefault(); - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.keyboardShortcutUsed, - properties: { - source: 'navigation-sidebar', - action: 'deleteItem', - }, - }); - showDeleteModal(focusedItem); - }, }); const { hasKonnectPat } = settings; @@ -991,7 +964,7 @@ const ProjectNavigationSidebarInner = ( className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)" data-testid="project-navigation-tree-container" onKeyDownCapture={(e: React.KeyboardEvent) => { - if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft' && e.key !== 'Delete') { return; } const active = document.activeElement; @@ -1003,6 +976,29 @@ const ProjectNavigationSidebarInner = ( if (!rowEl || rowEl !== active) { return; } + if (e.key === 'Delete') { + const { focusedItem } = getFocusedItem(); + if ( + !focusedItem || + (focusedItem.kind !== 'project' && + focusedItem.kind !== 'workspace' && + focusedItem.kind !== 'collectionChild' && + focusedItem.kind !== 'pinnedRequest') + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.keyboardShortcutUsed, + properties: { + source: 'navigation-sidebar', + action: 'deleteItem', + }, + }); + showDeleteModal(focusedItem); + return; + } const docId = (active.dataset.key || '').replace(/^pinned-request-/, ''); const item = visibleFlatItems.find(i => i.doc._id === docId && i.kind !== 'pinnedRequest'); if (!item) { From 95fcf1b4fcf98ea0549943307a6bc832c975ac5f Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:04:44 +0800 Subject: [PATCH 5/7] feat(Git Diff): Warn when a file isn't in the format Insomnia writes [8/8] (#10641) * feat(Git Diff): warn when a file isn't in the format Insomnia writes The Visual view matches entities by id and splices them into a file written the way Insomnia exports it, so a file in any other shape, eg. edited by hand, can show incomplete cards, and acting on a card rewrites the whole file. Each previewed diff now checks both sides by replaying them through Git Sync's import -> export: a side that doesn't match the Insomnia file format shows a note pointing to the Text view instead of cards, and a valid side Insomnia would write differently (comments, formatting, order, missing or duplicated parts, unrecognized fields, another version) shows a warning above the cards. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(Git Diff): show one format warning at most above the diff A file with unrecognized fields, or one whose change is only Insomnia reformatting it, already gets its own warning above the diff, which says more. The Visual view's format warning no longer stacks on top of those, and its text is shorter. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(Git Diff): check a file's format in the background, not in diffFileLoader The format check replays both sides of the diff through import -> export, which held up every diff, including in the Text view. It's now a separate loader the Visual view calls after rendering its cards, so the diff shows right away and the warning follows once the check is done. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- packages/insomnia/src/entry.preload.ts | 1 + packages/insomnia/src/main/git-service.ts | 42 +++++++++++++++- packages/insomnia/src/main/ipc/electron.ts | 1 + .../sync/git/__tests__/canonical-file.test.ts | 32 ++++++++++++ .../insomnia/src/sync/git/canonical-file.ts | 30 +++++++++-- .../modals/git-project-staging-modal.tsx | 3 ++ .../modals/visual-diff/entity-diff-list.tsx | 50 +++++++++++++++++++ 7 files changed, 155 insertions(+), 4 deletions(-) diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 6f0a511a5e..2832eaa66f 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -246,6 +246,7 @@ const git: GitServiceAPI = { stagePartialContent: options => invokeWithNormalizedError('git.stagePartialContent', options), discardPartialContent: options => invokeWithNormalizedError('git.discardPartialContent', options), diffFileLoader: options => invokeWithNormalizedError('git.diffFileLoader', options), + fileFormatIssueLoader: options => invokeWithNormalizedError('git.fileFormatIssueLoader', options), getRepositoryDirectoryTree: options => invokeWithNormalizedError('git.getRepositoryDirectoryTree', options), migrateLegacyInsomniaFolderToFile: options => invokeWithNormalizedError('git.migrateLegacyInsomniaFolderToFile', options), diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index b12673032c..d203ecbc58 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -51,7 +51,12 @@ import { insomniaSchemaTypeToScope } from '../common/insomnia-v5'; import { buildInsomniaChangeReport, type InsomniaChangeReport } from '../common/insomnia-v5-change-report'; import { checkStrictSchemaMatch } from '../common/zod/check-schema-mismatch'; import { findUnknownFieldRanges, type UnknownFieldRange } from '../common/zod/find-unknown-field-ranges'; -import { canonicalizeInsomniaFileContent, isUndiscardableChange } from '../sync/git/canonical-file'; +import { + canonicalizeInsomniaFileContent, + getInsomniaFileFormatIssue, + type InsomniaFileFormatIssue, + isUndiscardableChange, +} from '../sync/git/canonical-file'; import { fsClient } from '../sync/git/fs-client'; import { CURRENT_MIGRATION_VERSION, migrateRepoStructureIfNeeded } from '../sync/git/git-repo-migration'; import GitVCS, { @@ -3592,6 +3597,37 @@ export const diffFileLoader = async ({ } }; +/** + * Whether either side of a file's diff isn't a file as Insomnia writes it (eg. edited by hand), which the + * Visual view can't reliably show or act on. Kept out of `diffFileLoader` so the import -> export replay it + * takes doesn't hold up showing the diff. + */ +export const fileFormatIssueLoader = async ({ + projectId, + workspaceId, + filepath, + staged, +}: { + projectId: string; + workspaceId?: string; + filepath: string; + staged: boolean; +}): Promise => { + try { + await getGitRepository({ workspaceId, projectId }); + const fileStatus = await GitVCS.fileStatus(filepath); + // The raw contents: the diff's `before` is migrated and reordered for display + const sides = staged + ? [fileStatus.rawHead, fileStatus.rawStage] + : [fileStatus.rawStage || fileStatus.rawHead, fileStatus.workdir]; + const issues = await Promise.all(sides.map(getInsomniaFileFormatIssue)); + return issues.includes('invalid') ? 'invalid' : issues.includes('non-canonical') ? 'non-canonical' : null; + } catch (err) { + console.warn('[git] Failed to check the format of the file', filepath, err); + return null; + } +}; + interface GitRepoFile { id: string; name: string; @@ -4110,6 +4146,7 @@ export interface GitServiceAPI { stagePartialContent: typeof stagePartialContentAction; discardPartialContent: typeof discardPartialContentAction; diffFileLoader: typeof diffFileLoader; + fileFormatIssueLoader: typeof fileFormatIssueLoader; getRepositoryDirectoryTree: typeof getRepositoryDirectoryTree; migrateLegacyInsomniaFolderToFile: typeof migrateLegacyInsomniaFolderToFile; fetchGitRemoteBranches: typeof fetchGitRemoteBranches; @@ -4226,6 +4263,9 @@ export const registerGitServiceAPI = () => { discardPartialContentAction(options), ); ipcMainHandle('git.diffFileLoader', (_, options: Parameters[0]) => diffFileLoader(options)); + ipcMainHandle('git.fileFormatIssueLoader', (_, options: Parameters[0]) => + fileFormatIssueLoader(options), + ); ipcMainHandle('git.getRepositoryDirectoryTree', (_, options: Parameters[0]) => getRepositoryDirectoryTree(options), ); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 5fc2f864a0..5d35aaac74 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -62,6 +62,7 @@ export type HandleChannels = | 'git.deleteGitBranch' | 'git.diff' | 'git.diffFileLoader' + | 'git.fileFormatIssueLoader' | 'git.discardChanges' | 'git.fetchGitRemoteBranches' | 'git.getProjectGitFileIssues' diff --git a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts index ce097f5125..467c7d5403 100644 --- a/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/canonical-file.test.ts @@ -12,6 +12,7 @@ import { getInsomniaV5DataExport, tryImportV5Data } from '../../../common/insomn import { canonicalizeInsomniaFileContent, getCanonicalInsomniaFileContent, + getInsomniaFileFormatIssue, isUndiscardableChange, } from '../canonical-file'; @@ -146,6 +147,37 @@ routes: }); }); + describe('getInsomniaFileFormatIssue', () => { + it('finds no issue in a file Insomnia wrote itself', async () => { + expect(await getInsomniaFileFormatIssue(await exportThroughDatabase(reorderedCollection))).toBeNull(); + }); + + it('flags a valid file Insomnia would write differently, eg. one edited by hand', async () => { + const exported = await exportThroughDatabase(reorderedCollection); + const withComment = exported.replace('collection:\n', 'collection:\n # added by hand\n'); + const withDuplicateId = buildCollection([ + { id: 'fld_a', name: 'Folder A', created: 2000 }, + { id: 'fld_a', name: 'Folder A copy', created: 3000 }, + ]); + + expect(await getInsomniaFileFormatIssue(reorderedCollection)).toBe('non-canonical'); + expect(await getInsomniaFileFormatIssue(withComment)).toBe('non-canonical'); + expect(await getInsomniaFileFormatIssue(withDuplicateId)).toBe('non-canonical'); + }); + + it('flags an Insomnia file that does not match the file format', async () => { + const notAList = 'type: collection.insomnia.rest/5.0\nname: C\ncollection: not a list\n'; + + expect(await getInsomniaFileFormatIssue(notAList)).toBe('invalid'); + }); + + it('ignores empty sides and files that are not Insomnia files', async () => { + expect(await getInsomniaFileFormatIssue(null)).toBeNull(); + expect(await getInsomniaFileFormatIssue('')).toBeNull(); + expect(await getInsomniaFileFormatIssue('openapi: 3.0.0\ninfo:\n title: Spec\n')).toBeNull(); + }); + }); + describe('getCanonicalInsomniaFileContent', () => { it.each(insomniaFixtures)( 'matches the real import/export pipeline for %s', diff --git a/packages/insomnia/src/sync/git/canonical-file.ts b/packages/insomnia/src/sync/git/canonical-file.ts index 79e4e7b8da..788824775d 100644 --- a/packages/insomnia/src/sync/git/canonical-file.ts +++ b/packages/insomnia/src/sync/git/canonical-file.ts @@ -22,9 +22,7 @@ import { hasSignificantChanges } from '~/common/significant-diff-detection'; * or null if `content` is not an importable Insomnia file. */ export async function getCanonicalInsomniaFileContent(content: string): Promise { - const newLineIndex = content.indexOf('\n'); - const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim(); - if (!InsomniaFileTypeValues.some(type => firstLine.includes(type))) { + if (!isInsomniaFileContent(content)) { return null; } @@ -53,6 +51,32 @@ export async function getCanonicalInsomniaFileContent(content: string): Promise< return exportedContent || null; } +// Whether `content` declares itself an Insomnia file on its first line (eg. `type: collection.insomnia.rest/5.0`). +function isInsomniaFileContent(content: string) { + const newLineIndex = content.indexOf('\n'); + const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim(); + return InsomniaFileTypeValues.some(type => firstLine.includes(type)); +} + +export type InsomniaFileFormatIssue = 'invalid' | 'non-canonical'; + +/** + * How `content` differs from a file Insomnia writes itself: `invalid` when it's an Insomnia file that + * doesn't match the file format, `non-canonical` when it does but Insomnia would write it differently + * (eg. it was edited by hand — comments, formatting, order, missing or duplicated parts, unrecognized + * fields — or written by another version of Insomnia), and null when it's exactly what Insomnia writes, + * empty, or not an Insomnia file at all. + */ +export async function getInsomniaFileFormatIssue(content: string | null): Promise { + if (!content || !isInsomniaFileContent(content)) { + return null; + } + if (tryImportV5Data(content).error) { + return 'invalid'; + } + return (await getCanonicalInsomniaFileContent(content)) === content ? null : 'non-canonical'; +} + /** * `content` as the Git Sync pipeline would write it, or `content` itself when it can't be replayed * (not an importable Insomnia file, or a workspace the export can't produce, e.g. a file without a diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 2753ddbefc..8d98cd0c3a 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -1956,6 +1956,9 @@ const OriginalGitProjectStagingModal: FC< onShowTextView={() => setDiffViewMode('text')} actionGate={actionGate} isActionInFlight={isActionInFlight} + // One warning above the diff at most: unrecognized fields, or a change that is only Insomnia + // reformatting the file, already have their own, which say more than the format warning would. + hideFormatWarning={Boolean(hasUnknownFields || undiscardableFilePath)} /> )} diff --git a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx index 512af15f9d..14e1efcf2c 100644 --- a/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx +++ b/packages/insomnia/src/ui/components/modals/visual-diff/entity-diff-list.tsx @@ -5,6 +5,7 @@ import { useGitProjectDiscardPartialContentActionFetcher } from '~/routes/git.di import { useGitProjectStagePartialContentActionFetcher } from '~/routes/git.stage-entity'; import { showToast } from '../../toast-notification'; +import { WarningBanner } from '../../warning-banner'; import type { ActionGate } from './action-gate'; import { CookieJarDiffCard } from './cookie-jar-diff-card'; import { computeVisualDiff, getBaseEnvironmentId } from './diff-engine'; @@ -41,8 +42,12 @@ interface Props { actionGate: ActionGate; // Whether an action, here or file-level, is still in flight: every card's buttons wait for it. isActionInFlight: boolean; + // Set when the diff already has a warning saying more than the format warning would (eg. unrecognized fields). + hideFormatWarning?: boolean; } +type FormatIssue = Awaited>; + // Shown instead of cards when there are none to show; the Text view still has the full diff. const TextViewHint: FC<{ message: string; onShowTextView: () => void }> = ({ message, onShowTextView }) => (
@@ -67,8 +72,32 @@ export const EntityDiffList: FC = ({ onShowTextView, actionGate, isActionInFlight, + hideFormatWarning, }) => { const { entities, unparseable } = useMemo(() => computeVisualDiff(before, after), [before, after]); + + // Whether either side isn't a file as Insomnia writes it (eg. edited by hand): `invalid` doesn't match the + // Insomnia file format at all, `non-canonical` does but Insomnia would write it differently. Checked in the + // background, as it replays both sides through import -> export, so it doesn't hold up showing the diff. + // Kept per file, so a refresh after an action doesn't flash the warning, and another file never shows it. + const fileKey = `${filepath}:${staged}`; + const [formatCheck, setFormatCheck] = useState<{ fileKey: string; issue: FormatIssue } | null>(null); + useEffect(() => { + let cancelled = false; + window.main.git + .fileFormatIssueLoader({ projectId, workspaceId, filepath, staged }) + .then(issue => { + if (!cancelled) { + setFormatCheck({ fileKey, issue }); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [projectId, workspaceId, filepath, staged, fileKey, before, after]); + const formatIssue = formatCheck?.fileKey === fileKey ? formatCheck.issue : null; + const baseEnvironmentId = useMemo(() => getBaseEnvironmentId(after), [after]); const stagePartialContentFetcher = useGitProjectStagePartialContentActionFetcher(); const discardPartialContentFetcher = useGitProjectDiscardPartialContentActionFetcher(); @@ -152,6 +181,15 @@ export const EntityDiffList: FC = ({ return ; } + if (formatIssue === 'invalid') { + return ( + + ); + } + if (entities.length === 0) { // Also covers metadata-only (eg. timestamps) and formatting-only changes, and non-Insomnia files // such as the project's .spectral.yaml lint ruleset — not just moves. @@ -173,6 +211,18 @@ export const EntityDiffList: FC = ({ return (
+ {formatIssue === 'non-canonical' && !hideFormatWarning && ( + // Cards are matched up by id and actions splice entities into a file written the way Insomnia exports it, + // so a file in any other shape (eg. edited by hand) can show incomplete cards and lose what isn't an entity. + + This file isn't in the format Insomnia writes, eg. it was edited by hand or by another version. Cards may + be incomplete, and staging or discarding a card rewrites the whole file, losing comments and formatting.{' '} + {' '} + for the exact changes. + + )}
{entities.length} {entities.length === 1 ? 'entity' : 'entities'} changed From 25858ff7c8dbdaaf0c51194a41afaef7b4bc2b06 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 22 Oct 2026 16:25:03 +0800 Subject: [PATCH 6/7] fix(one-line-editor): after clicking add button, focus should be on the new one line editor (#10631) * fix: after clicking add button, focus should be on the new one line editor * fix: comment --- .../tests/smoke/focus-and-keyboard.test.ts | 16 ++++++++++++++++ .../.client/codemirror/one-line-editor.tsx | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts index 8120641a41..fd1e0ac6f9 100644 --- a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts @@ -50,6 +50,22 @@ test.describe('Focus and keyboard navigation', () => { await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1); }); + test('clicking Add moves focus off the Add button so typing lands in the new Name cell', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + + await page.getByRole('tab', { name: 'Params' }).click(); + const addButton = page.getByTestId('request-pane').getByRole('button', { name: 'Add', exact: true }); + await addButton.click(); + + // The Add button keeps focus until the new editor takes it; the autofocus re-grab must not treat + // it as the user having deliberately moved to another control. + await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1); + await expect.soft(addButton).not.toBeFocused(); + + await page.keyboard.type('smoke-key'); + await expect.soft(page.locator('.editor__container:has([id^="key-value-editor__name"]) .CodeMirror-code').first()).toHaveText('smoke-key'); + }); + test('the KV environment editor focuses the blank row Name', async ({ page }) => { await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index 9bb240c513..1b94f950ec 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -378,6 +378,11 @@ export const OneLineEditor = forwardRef // moved to another control (e.g. Tab from the URL bar to Send) — until the editor holds focus // or the window elapses. const deadline = Date.now() + 500; + // The control that held focus when we mounted (typically the "Add" button that triggered this + // editor). It keeps focus until we take it, so it must not be mistaken for the user having + // deliberately moved elsewhere. Cleared once the editor holds focus, so a later deliberate + // return to that control is respected. + let initialActiveElement = document.activeElement; const ensureFocus = () => { const cm = codeMirror.current; if (!cm) { @@ -399,6 +404,7 @@ export const OneLineEditor = forwardRef const role = active?.getAttribute('role'); const userMovedToAnotherControl = !!active && + active !== initialActiveElement && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT' || @@ -416,6 +422,8 @@ export const OneLineEditor = forwardRef } cm.focus(); cm.getDoc().setCursor(cm.getDoc().lineCount(), 0); + } else { + initialActiveElement = null; } if (Date.now() < deadline) { requestAnimationFrame(ensureFocus); From fee93b675a2bc1d46b0ef88a871d78409bf00609 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 22 Oct 2026 17:24:47 +0800 Subject: [PATCH 7/7] fix issues caused by undici bump (#10645) --- .../src/main/mcp/transport-streamable-http.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/main/mcp/transport-streamable-http.ts b/packages/insomnia/src/main/mcp/transport-streamable-http.ts index 9b413154a5..400f5e91ba 100644 --- a/packages/insomnia/src/main/mcp/transport-streamable-http.ts +++ b/packages/insomnia/src/main/mcp/transport-streamable-http.ts @@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { BrowserWindow } from 'electron'; import type { McpResponse, RequestHeader } from 'insomnia-data'; import { models, services } from 'insomnia-data'; -import type { Dispatcher } from 'undici'; +import { type Dispatcher, fetch as undiciFetch } from 'undici'; import { redactConfidentialText } from '~/common/network/sensitive-value-collector'; import { type ConnectionContext, getFetchDispatcher, writeEventLogAndNotify, writeTimeline } from '~/main/mcp/common'; @@ -21,6 +21,11 @@ interface NodeRequestInit extends RequestInit { dispatcher?: Dispatcher; } +// Use undici's own fetch: the global (Electron built-in) fetch ships an older undici whose handler API +// is incompatible with dispatchers created by the installed undici. +const dispatcherFetch = (url: string | URL, init: NodeRequestInit) => + undiciFetch(url, init as Parameters[1]) as unknown as Promise; + export const createStreamableHTTPTransport = async ( context: ConnectionContext, options: OpenMcpHTTPClientConnectionOptions, @@ -110,7 +115,7 @@ const wrappedFetch = async ( const requestMethodLine = `${method.toUpperCase()} ${url} ${isJsonRequest && requestBody?.method ? `\nJSON-RPC Method: ${requestBody.method}` : ''}`; const headersOut = requestHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n'); const start = performance.now(); - const response = await fetch(url, { + const response = await dispatcherFetch(url, { ...init, dispatcher: await getFetchDispatcher(requestId), } as NodeRequestInit); @@ -205,7 +210,7 @@ const wrappedFetch = async ( }; writeEventLogAndNotify(context, authRequestEvent); try { - const response = await fetch(url, { + const response = await dispatcherFetch(url, { ...init, dispatcher: await getFetchDispatcher(requestId), } as NodeRequestInit);