From f241c1f4cc6f2ab06fcb2ff940a193b051dcb05f Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 7 Oct 2026 22:36:55 +0300 Subject: [PATCH] fix(http): stop client fault rules when http is turned off When the overlay connects with the http inspector or actions.http off, it deletes the client fault rules stored in sessionStorage and the interceptor stops applying stored rules for the rest of that load. With both on, rules still persist across reloads. Requests made before the overlay connects can still use stale rules once. Closes #69 --- apps/docs/src/content/inspectors/ssr-http.md | 2 +- .../src/__tests__/overlay-config.test.ts | 88 ++++++++++++++++++- packages/devtools/src/http-rules.ts | 11 ++- packages/devtools/src/overlay.ts | 4 +- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 42f23fce..34096a12 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -179,7 +179,7 @@ The devtools don't write SSR mocks to TransferState, so the browser requests the Client rules apply right away. SSR rules apply from the next page load, so the panel asks for a reload only when a rule applies on SSR. The page also keeps client rules in `sessionStorage`, so they apply on reload before the overlay connects. Rules live in the memory of the server process. They survive a Vite restart in the same process, such as after a config edit, and the **SSR & HTTP** tab keeps showing them. A new process starts with none. -If the `http` inspector or `actions.http` is off, the server clears its rules when it starts. SSR rules apply only while the devtools server runs, so they stop when it closes, for example after a config edit that removes the Vite plugin. The overlay removes the stored client rules when it connects with the `http` inspector off, so requests made before it connects on that load can still fail. +If the `http` inspector or `actions.http` is off, the server clears its rules when it starts. SSR rules apply only while the devtools server runs, so they stop when it closes, for example after a config edit that removes the Vite plugin. When the overlay connects with the `http` inspector or `actions.http` off, it removes the stored client rules and the page stops applying client rules until a later load turns both back on. Requests made before the overlay connects on that load can still fail once. ### Timeline and rule caps diff --git a/packages/devtools/src/__tests__/overlay-config.test.ts b/packages/devtools/src/__tests__/overlay-config.test.ts index e50908be..eedbb10f 100644 --- a/packages/devtools/src/__tests__/overlay-config.test.ts +++ b/packages/devtools/src/__tests__/overlay-config.test.ts @@ -1,12 +1,24 @@ // @vitest-environment jsdom +import '@angular/compiler'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HttpRequest, HttpResponse } from '@angular/common/http'; +import { Injector, PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { of } from 'rxjs'; import type { PangularConfig } from '../config.ts'; import { isSecretKey, setRedaction } from '../forms-privacy.ts'; -import { RULES_STORAGE_KEY, clientRules, httpRegistry, storeRules } from '../http-rules.ts'; +import { pangularHttpInterceptor } from '../http.ts'; +import { + RULES_STORAGE_KEY, + clientRules, + httpRegistry, + storeRules, + type HttpRule, +} from '../http-rules.ts'; import { noteFailedCall, setNavigationLimit, type NavigationRecord } from '../router.ts'; const calls: string[] = []; const sentArgs = new Map(); +const replies = new Map(); let configs: Record | undefined; vi.mock('devframe/client', () => ({ @@ -17,7 +29,7 @@ vi.mock('devframe/client', () => ({ call: async (name: string, arg?: unknown) => { calls.push(name); sentArgs.set(name, arg); - return undefined; + return replies.get(name); }, register: () => {}, }, @@ -39,13 +51,47 @@ async function start(config?: PangularConfig) { afterEach(() => { stops.splice(0).forEach((stop) => stop()); sessionStorage.clear(); + replies.clear(); setRedaction(); setNavigationLimit(50); delete httpRegistry().maxCalls; delete httpRegistry().rules; + delete httpRegistry().rulesOff; vi.restoreAllMocks(); }); +const faultRule: HttpRule = { + id: 'r1', + pattern: '/api', + enabled: true, + target: 'client', + status: 500, +}; + +function reload() { + stops.splice(0).forEach((stop) => stop()); + delete httpRegistry().rules; + delete httpRegistry().rulesOff; +} + +function requestFaulted(): boolean { + const g = globalThis as { ngDevMode?: unknown }; + const saved = g.ngDevMode; + g.ngDevMode ??= true; + httpRegistry().calls = []; + const injector = Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'browser' }] }); + try { + runInInjectionContext(injector, () => + pangularHttpInterceptor(new HttpRequest('GET', '/api/products'), () => + of(new HttpResponse({ status: 200, body: [] })), + ), + ).subscribe({ error: () => {} }); + return httpRegistry().calls?.at(-1)?.faulted === true; + } finally { + g.ngDevMode = saved; + } +} + describe('overlay collectors', () => { it('ping instead of resending unchanged trees and HTTP calls', async () => { await start({ limits: { refreshMs: 500 } }); @@ -147,11 +193,47 @@ describe('overlay collectors', () => { }); it('drop stored fault rules when the http inspector is off', async () => { - storeRules([{ id: 'r1', pattern: '/api', enabled: true, target: 'client', status: 500 }]); + storeRules([faultRule]); + expect(requestFaulted()).toBe(true); await start({ inspectors: { http: false } }); expect(sessionStorage.getItem(RULES_STORAGE_KEY)).toBeNull(); + expect(requestFaulted()).toBe(false); + reload(); + expect(clientRules()).toEqual([]); + expect(requestFaulted()).toBe(false); + }); + + it('drop stored fault rules when the http action is off', async () => { + replies.set('get-http-rules', [faultRule]); + storeRules([faultRule]); + await start({ actions: { http: false } }); + expect(sessionStorage.getItem(RULES_STORAGE_KEY)).toBeNull(); + expect(requestFaulted()).toBe(false); + reload(); + expect(clientRules()).toEqual([]); + expect(requestFaulted()).toBe(false); + }); + + it('ignore rules stored after the config turned http off', async () => { + await start({ inspectors: { http: false } }); + storeRules([faultRule]); + sessionStorage.setItem(RULES_STORAGE_KEY, JSON.stringify([faultRule])); delete httpRegistry().rules; expect(clientRules()).toEqual([]); + expect(requestFaulted()).toBe(false); + }); + + it('keep fault rules across a reload while http is on', async () => { + replies.set('get-http-rules', [faultRule]); + storeRules([faultRule]); + await start(); + expect(requestFaulted()).toBe(true); + reload(); + expect(clientRules()).toEqual([faultRule]); + expect(requestFaulted()).toBe(true); + await start(); + expect(JSON.parse(sessionStorage.getItem(RULES_STORAGE_KEY) ?? '[]')).toEqual([faultRule]); + expect(requestFaulted()).toBe(true); }); it('apply the redaction config from the server before collecting', async () => { diff --git a/packages/devtools/src/http-rules.ts b/packages/devtools/src/http-rules.ts index 1c0a0044..914c358e 100644 --- a/packages/devtools/src/http-rules.ts +++ b/packages/devtools/src/http-rules.ts @@ -53,6 +53,7 @@ export interface HttpRegistry { dispose?: () => void; /** The hub context whose setup installed `record` and `dispose`. */ owner?: unknown; + rulesOff?: boolean; } export const MAX_CALLS = 200; @@ -71,7 +72,9 @@ export const RULES_STORAGE_KEY = 'pangular:http-rules'; * next load, before the overlay connects. */ export function storeRules(rules: HttpRule[]) { - httpRegistry().rules = rules; + const registry = httpRegistry(); + if (registry.rulesOff) rules = []; + registry.rules = rules; try { if (rules.length) sessionStorage.setItem(RULES_STORAGE_KEY, JSON.stringify(rules)); else sessionStorage.removeItem(RULES_STORAGE_KEY); @@ -80,8 +83,14 @@ export function storeRules(rules: HttpRule[]) { } } +export function allowClientRules(allowed: boolean) { + httpRegistry().rulesOff = !allowed; + if (!allowed) storeRules([]); +} + export function clientRules(): HttpRule[] { const registry = httpRegistry(); + if (registry.rulesOff) return []; if (!registry.rules) { try { registry.rules = sanitizeRules(JSON.parse(sessionStorage.getItem(RULES_STORAGE_KEY) ?? '[]')); diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index e9803285..0546ce03 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -4,7 +4,7 @@ import { attachAnalog } from './analog-runtime.ts'; import { attachForms } from './forms-collector.ts'; import { attachPipes } from './pipes-collector.ts'; import { attachHttp } from './http-overlay.ts'; -import { httpRegistry, storeRules } from './http-rules.ts'; +import { allowClientRules, httpRegistry } from './http-rules.ts'; import { attachNgrx } from './ngrx-overlay.ts'; import { collectInjectorTree } from './injector-tree.ts'; import { @@ -197,7 +197,7 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) const devtoolsConfig = configFromConnection(rpc.connectionMeta); const on = devtoolsConfig.inspectors; const limits = devtoolsConfig.limits; - if (!on.http) storeRules([]); + allowClientRules(on.http && devtoolsConfig.actions.http); setRedaction(devtoolsConfig.redaction); setNavigationLimit(limits.navigations); if (on.http) httpRegistry().maxCalls = limits.httpCalls;