From 313a8abe81753427067b5cad428281603c998319 Mon Sep 17 00:00:00 2001 From: Kam Date: Tue, 6 Oct 2026 20:57:47 +0300 Subject: [PATCH] fix(http): stop SSR fault rules once their devtools server closes Server rules live on globalThis so they survive a Vite restart, but if a config edit removed the plugin the disposed hub left them applying to every SSR request. The interceptor now applies server rules only while a devtools server owns the registry, so a normal restart keeps them and a closed server stops them. Refs #69 --- apps/docs/src/content/inspectors/ssr-http.md | 2 +- .../src/__tests__/vite-restart.test.ts | 62 ++++++++++++++++++- packages/devtools/src/http.ts | 3 +- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 3b3b5a40..42f23fce 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. 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. 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. ### Timeline and rule caps diff --git a/packages/devtools/src/__tests__/vite-restart.test.ts b/packages/devtools/src/__tests__/vite-restart.test.ts index f59dd35b..631bd7c3 100644 --- a/packages/devtools/src/__tests__/vite-restart.test.ts +++ b/packages/devtools/src/__tests__/vite-restart.test.ts @@ -1,8 +1,13 @@ +import '@angular/compiler'; +import { Injector, PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { HttpRequest, HttpResponse, type HttpEvent } from '@angular/common/http'; import { createHostContext } from 'devframe/node'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { of, type Observable } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import pangular from '../devframe.ts'; import { clearCalls, recordCall, type AnalogCall } from '../analog-server-log.ts'; -import { httpRegistry } from '../http-rules.ts'; +import { pangularHttpInterceptor } from '../http.ts'; +import { httpRegistry, type HttpRule } from '../http-rules.ts'; import type { AnalogState } from '../rpc/analog-tools.ts'; import { releaseServerState } from '../vite.ts'; @@ -59,3 +64,56 @@ describe('releaseServerState', () => { expect((await current.analog()).calls).toHaveLength(0); }); }); + +describe('server fault rules across a Vite restart', () => { + const g = globalThis as { ngDevMode?: unknown }; + const saved = g.ngDevMode; + const rule: HttpRule = { + id: 'r1', + pattern: '/api', + enabled: true, + target: 'server', + status: 503, + }; + const injector = Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'server' }] }); + const ssrStatus = () => + new Promise((resolve) => { + const result = runInInjectionContext(injector, () => + pangularHttpInterceptor(new HttpRequest('GET', 'http://localhost/api/items'), () => + of(new HttpResponse({ status: 200 })), + ), + ) as Observable>; + result.subscribe({ + next: (event) => { + if (event instanceof HttpResponse) resolve(event.status); + }, + error: (error: { status: number }) => resolve(error.status), + }); + }); + + beforeEach(() => { + g.ngDevMode = true; + }); + + afterEach(() => { + g.ngDevMode = saved; + httpRegistry().dispose?.(); + delete httpRegistry().rules; + }); + + it('keeps applying the rules when a new server takes over in the same process', async () => { + const old = await boot(); + httpRegistry().rules = [rule]; + await boot(); + releaseServerState(old.ctx); + expect(await ssrStatus()).toBe(503); + }); + + it('stops applying the rules once the server that owns them closes', async () => { + const current = await boot(); + httpRegistry().rules = [rule]; + expect(await ssrStatus()).toBe(503); + releaseServerState(current.ctx); + expect(await ssrStatus()).toBe(200); + }); +}); diff --git a/packages/devtools/src/http.ts b/packages/devtools/src/http.ts index fa573f78..a72e7ffc 100644 --- a/packages/devtools/src/http.ts +++ b/packages/devtools/src/http.ts @@ -189,7 +189,8 @@ export const pangularHttpInterceptor: HttpInterceptorFn = (req, next) => { pageUrl = location.pathname + location.search; } const url = req.urlWithParams; - const rules = side === 'client' ? clientRules() : httpRegistry().rules; + const registry = httpRegistry(); + const rules = side === 'client' ? clientRules() : registry.record && registry.rules; const rule = matchRule(url, req.method, rules, side); const started = Date.now(); const delay = Math.min(Math.max(rule?.delayMs ?? 0, 0), MAX_DELAY_MS);