rxjs-leak-finder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_IT_WORKS.md +214 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli/dashboard-server.d.ts +11 -0
- package/dist/cli/dashboard-server.js +241 -0
- package/dist/cli/dashboard-server.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +67 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/dashboard/assets/index-BqI1esY6.js +350 -0
- package/dist/dashboard/assets/mappings-Dhu04MXZ.wasm +0 -0
- package/dist/dashboard/index.html +25 -0
- package/dist/runtime/enable.d.ts +29 -0
- package/dist/runtime/enable.js +60 -0
- package/dist/runtime/enable.js.map +1 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/patch.d.ts +11 -0
- package/dist/runtime/patch.js +42 -0
- package/dist/runtime/patch.js.map +1 -0
- package/dist/runtime/recorder.d.ts +15 -0
- package/dist/runtime/recorder.js +94 -0
- package/dist/runtime/recorder.js.map +1 -0
- package/dist/runtime/route-tracker.d.ts +8 -0
- package/dist/runtime/route-tracker.js +130 -0
- package/dist/runtime/route-tracker.js.map +1 -0
- package/dist/runtime/transport.d.ts +12 -0
- package/dist/runtime/transport.js +85 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/types.d.ts +96 -0
- package/dist/runtime/types.js +7 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/runtime/widget.d.ts +11 -0
- package/dist/runtime/widget.js +61 -0
- package/dist/runtime/widget.js.map +1 -0
- package/package.json +61 -0
package/HOW_IT_WORKS.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# How `rxjs-leak-finder` works
|
|
2
|
+
|
|
3
|
+
Plain words first. The technical bits are at the bottom.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
In an Angular app, you write code like this:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
ngOnInit() {
|
|
13
|
+
interval(1000).subscribe(n => this.tick = n);
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
When the user navigates away from this page, Angular destroys the component. But the `interval` doesn't know that. It keeps running. The callback keeps a reference to `this`. Garbage collection can't free the component. **That's a memory leak.**
|
|
18
|
+
|
|
19
|
+
These leaks are usually invisible. The app feels a little slower over time, then suddenly very slow, then crashes. By then you have no idea which page introduced it.
|
|
20
|
+
|
|
21
|
+
What we want: a list of subscriptions that *should* have been cleaned up but weren't, with enough information to fix them — the **component**, the **file:line** of the subscribe, and a hint about **why** it leaked.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## The trick: every Subscription gets a name tag
|
|
26
|
+
|
|
27
|
+
When you call `someObservable.subscribe(...)`, RxJS returns a `Subscription` object. Normally it's anonymous: you can't tell where it came from once it exists.
|
|
28
|
+
|
|
29
|
+
The detector replaces RxJS's `subscribe` method with a wrapper. Every time anyone subscribes:
|
|
30
|
+
|
|
31
|
+
1. Call the original `subscribe`. Get the Subscription back.
|
|
32
|
+
2. Capture a stack trace — `new Error().stack` is JavaScript's way of asking "who's calling me right now?".
|
|
33
|
+
3. Attach a hidden property `__sw_meta` to the Subscription, holding: id, timestamp, current route, the stack trace, what kind of Observable it was.
|
|
34
|
+
4. Also patch the Subscription's `unsubscribe` so we know when it was cleaned up.
|
|
35
|
+
|
|
36
|
+
Now every Subscription carries its own name tag. You can ask any one of them: "Where did you come from? Were you ever cleaned up?"
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
┌──────────────────────────────────────────────────┐
|
|
40
|
+
│ user code │
|
|
41
|
+
│ interval(1000).subscribe(...) │
|
|
42
|
+
│ │ │
|
|
43
|
+
│ ▼ │
|
|
44
|
+
│ ╭─────────── patchedSubscribe ─────────────╮ │
|
|
45
|
+
│ │ 1. call real subscribe │ │
|
|
46
|
+
│ │ 2. snapshot the stack │ │
|
|
47
|
+
│ │ 3. attach __sw_meta to the Subscription │ │
|
|
48
|
+
│ │ 4. ensure unsubscribe is patched too │ │
|
|
49
|
+
│ ╰──────────────────────────────────────────╯ │
|
|
50
|
+
└──────────────────────────────────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The patch lives in `src/runtime/patch.ts`. About 50 lines.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Recording a session
|
|
58
|
+
|
|
59
|
+
Patching every `subscribe()` is cheap, but storing every tag forever would be a lot. So the detector only *remembers* tags during a recording session. A session has three states:
|
|
60
|
+
|
|
61
|
+
| State | What happens |
|
|
62
|
+
|---|---|
|
|
63
|
+
| idle | Patch is installed. Tags are attached to Subscriptions. None are stored. |
|
|
64
|
+
| recording | Every new tag is added to the recorder's map. Route changes are recorded. |
|
|
65
|
+
| stopped | The session is sent to the dashboard (a JSON POST), then the map is cleared. |
|
|
66
|
+
|
|
67
|
+
You start a session by clicking **● Rec** in the floating widget. Stop with **■ Stop**. The widget is in `src/runtime/widget.ts` — three small DOM nodes pinned to `position: fixed; top-right`. Clicking through this calls into the controller (`src/runtime/enable.ts`).
|
|
68
|
+
|
|
69
|
+
The recorder lives in `src/runtime/recorder.ts`. It's a plain object with a few methods:
|
|
70
|
+
|
|
71
|
+
- `start()` — clear, mark recording.
|
|
72
|
+
- `onSubscribe(sub, obs, stack)` — store a tag.
|
|
73
|
+
- `onUnsubscribe(sub)` — mark the tag as closed.
|
|
74
|
+
- `stop()` — return the report and clear.
|
|
75
|
+
|
|
76
|
+
No RxJS, no Angular, no DOM. Just data.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Tracking which route you're on
|
|
81
|
+
|
|
82
|
+
A subscribe on `/products` is a leak only if you've left `/products`. So we need to know what route was active when each tag was captured, and what route changes happened during the session.
|
|
83
|
+
|
|
84
|
+
The Router's events are framework-specific. To stay framework-agnostic, we patch the **History API** instead: `pushState`, `replaceState`, and the `popstate` event. Every Angular Router (and Vue, and SvelteKit, and plain `<a>` links) eventually goes through these. Whenever any of them fires, we record `{ from, to }`.
|
|
85
|
+
|
|
86
|
+
This lives in `src/runtime/route-tracker.ts`.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Getting the report to the dashboard
|
|
91
|
+
|
|
92
|
+
When you click **■ Stop**:
|
|
93
|
+
|
|
94
|
+
1. The recorder returns a `RecordingReport`: meta + navigations + array of subscription tags.
|
|
95
|
+
2. `sendReport()` (`src/runtime/transport.ts`) tries `POST /report` to `http://localhost:7654`.
|
|
96
|
+
3. **If the dashboard is offline** the report is stashed in `localStorage`. Next time the app boots, `flushQueue()` retries.
|
|
97
|
+
|
|
98
|
+
This means you can record without the dashboard running, then start it later and not lose the session.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## The dashboard server
|
|
103
|
+
|
|
104
|
+
`npx rxjs-leak-finder dashboard` starts a tiny Node HTTP server (`src/cli/dashboard-server.ts`). It has five endpoints:
|
|
105
|
+
|
|
106
|
+
| Endpoint | Purpose |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `POST /report` | Save a recording report as `.rld/<timestamp>-<id>.json`. |
|
|
109
|
+
| `GET /sessions` | List all `.rld/*.json` files. |
|
|
110
|
+
| `GET /sessions/:id` | Read one report. |
|
|
111
|
+
| `GET /source-maps?url=…` | Server-side fetch of source-map files (CORS bypass for the SPA). |
|
|
112
|
+
| `POST /open` | Spawn the user's editor at `<file>:<line>:<col>`. |
|
|
113
|
+
| `GET /*` | Serve the built dashboard SPA (Lit + Vite). |
|
|
114
|
+
|
|
115
|
+
Reports are plain JSON files. You can commit them, share them, version them. The server doesn't run any analysis — that happens entirely in the dashboard SPA.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Resolving stacks to file:line
|
|
120
|
+
|
|
121
|
+
The stack trace captured at subscribe time looks like:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
at patchedSubscribe (http://localhost:4200/vite/deps/rxjs-leak-finder.js:120:23)
|
|
125
|
+
at SimpleLeakPage.ngOnInit (http://localhost:4200/src/app/pages/simple-leak.page.ts:24:8)
|
|
126
|
+
at callHookInternal (http://localhost:4200/vite/deps/@angular_core.js:4140:14)
|
|
127
|
+
…
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
To turn `http://localhost:4200/src/app/pages/simple-leak.page.ts:24:8` into the actual source file:line, the dashboard:
|
|
131
|
+
|
|
132
|
+
1. Fetches the JS file from the server.
|
|
133
|
+
2. Reads the `//# sourceMappingURL=…` comment at the bottom.
|
|
134
|
+
3. Fetches the source map.
|
|
135
|
+
4. Asks Mozilla's `source-map` library: "what original position maps to line 24, column 8?"
|
|
136
|
+
|
|
137
|
+
This work happens in the dashboard SPA (`src/dashboard/app.ts`) because source maps are big and we want the server stateless. The WASM file for `source-map` (~48 kB) is shipped with the dashboard build.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Classifying leaks
|
|
142
|
+
|
|
143
|
+
A "leak" is a subscription that:
|
|
144
|
+
|
|
145
|
+
- Was created during the recording window.
|
|
146
|
+
- Was on a route the user has since *left* (per the navigation log).
|
|
147
|
+
- Has at least one non-framework frame in its stack.
|
|
148
|
+
- Was never unsubscribed.
|
|
149
|
+
|
|
150
|
+
That's the binary classification. Beyond that, the dashboard adds a **kind** so you know *why* it likely leaked. Heuristics from `packages/analyzer-core/src/leak-classifier.ts`:
|
|
151
|
+
|
|
152
|
+
| Kind | How we recognize it |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `nested-subscribe` | Two or more `patchedSubscribe` frames in the same stack. |
|
|
155
|
+
| `async-init` | TS's `__async` / `ZoneAwarePromise` helper between the subscribe and a `ngOnInit` frame. |
|
|
156
|
+
| `ng-init` | The top non-framework frame is `*.ngOnInit`. |
|
|
157
|
+
| `global-event` | `fromEvent` / `addEventListener` in the stack. |
|
|
158
|
+
| `timer` | `interval` / `timer` in the stack, or the observable kind is plain `Observable`. |
|
|
159
|
+
| `subject` | The observable's `constructor.name` includes `Subject`. |
|
|
160
|
+
|
|
161
|
+
Heuristics are best-effort and conservative. When in doubt, kind = `unknown`.
|
|
162
|
+
|
|
163
|
+
The same module exposes `extractComponentName(frames)` (scans frames for a class name matching `*Component` / `*Directive` / `*Page` / `*Dialog`) and `stripDetectorFrames(frames)` (drops the detector's own `patchedSubscribe` from the displayed stack).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Why no Chrome extension?
|
|
168
|
+
|
|
169
|
+
A Chrome extension would need DevTools access, content-script injection, message passing, a separate manifest, store reviews. For something this scoped — a dev-mode tool you run yourself — a floating widget + local HTTP server is dramatically simpler. No installs beyond `npm install`. Works in any browser, including headless Chrome.
|
|
170
|
+
|
|
171
|
+
The trade-off: no heap-snapshot integration. That's the one capability a DevTools extension would unlock (walking retainer chains to *prove* what's holding the subscription). The analyzer-core package has that code (`analyze.ts`, `heap-parser.ts`, `retainer-walker.ts`), but the dashboard doesn't currently invoke it — recording-only is good enough for ~95% of leaks. If you want retainer info, save a `.heapsnapshot` and feed it into `analyzer-core` programmatically.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Package layout
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
rxjs-leak-finder (published)
|
|
179
|
+
├─ src/runtime/ ← code that ships into your Angular bundle
|
|
180
|
+
│ ├─ enable.ts the public entry point
|
|
181
|
+
│ ├─ patch.ts monkey-patches Observable.prototype.subscribe
|
|
182
|
+
│ ├─ recorder.ts session state machine
|
|
183
|
+
│ ├─ route-tracker.ts patches History API
|
|
184
|
+
│ ├─ widget.ts floating UI in your app
|
|
185
|
+
│ └─ transport.ts fetch with offline localStorage queue
|
|
186
|
+
├─ src/cli/ ← node-only, runs the dashboard server
|
|
187
|
+
│ ├─ index.ts arg parsing + lifecycle
|
|
188
|
+
│ └─ dashboard-server.ts
|
|
189
|
+
└─ src/dashboard/ ← the SPA (Lit + Vite, bundled at build)
|
|
190
|
+
└─ app.ts
|
|
191
|
+
|
|
192
|
+
@rld/analyzer-core (workspace dep, bundled into the dashboard)
|
|
193
|
+
├─ types.ts shared types
|
|
194
|
+
├─ leak-classifier.ts leakKind + componentName heuristics
|
|
195
|
+
├─ source-map-resolver.ts
|
|
196
|
+
├─ classifier.ts verdict (leak / long-lived / framework-noise)
|
|
197
|
+
├─ heap-parser.ts (optional) V8 heap snapshot parser
|
|
198
|
+
└─ retainer-walker.ts (optional) walks retainer chains from a heap snapshot
|
|
199
|
+
|
|
200
|
+
@rld/panel-ui (workspace dep, bundled into the dashboard)
|
|
201
|
+
└─ Lit components: leak-list, leak-row, leak-detail, leak-summary, stack-frame
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`@rld/analyzer-core` and `@rld/panel-ui` are devDependencies — Vite inlines them at build time, so users only install `rxjs-leak-finder`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Things that surprised me
|
|
209
|
+
|
|
210
|
+
- **`new Error().stack` is the cheapest reliable stack trace.** No async stacks needed; the JS engine produces it eagerly.
|
|
211
|
+
- **History API patching is more universal than Router hooks.** Every framework eventually calls `pushState`. Routers are an abstraction over the same thing.
|
|
212
|
+
- **Vite renames RxJS internals at dev time.** `BehaviorSubject` becomes `BehaviorSubject2`, so we can't use `instanceof Subject` reliably. We use `constructor.name` instead.
|
|
213
|
+
- **The first frame of every captured stack is always `patchedSubscribe`.** We strip it before display.
|
|
214
|
+
- **`takeUntilDestroyed()` after an `await` silently no-ops.** This is the bug that makes `async ngOnInit` so dangerous; the `async-init` kind catches it.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florin Ciocirlan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# rxjs-leak-finder
|
|
2
|
+
|
|
3
|
+
> Find leaked RxJS subscriptions in your Angular dev-mode app.
|
|
4
|
+
> One line in `main.ts`, a floating widget, a local dashboard. No Chrome extension.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/rxjs-leak-finder)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
You add one line to `main.ts`. The detector patches `Observable.prototype.subscribe` and starts watching. You navigate around your app and click **Stop** in the floating widget. The detector POSTs the report to a local dashboard, which highlights subscriptions that were created on a route you left without ever being unsubscribed. Each leak shows the **component**, the **file:line** where it was subscribed, and a category (`nested-subscribe`, `async-init`, `ng-init`, `global-event`, `timer`, `subject`).
|
|
14
|
+
|
|
15
|
+
It works on any Angular dev-mode app — standalone or NgModule, signals or RxJS, ChangeDetectionStrategy.OnPush or default.
|
|
16
|
+
|
|
17
|
+
See [HOW_IT_WORKS.md](./HOW_IT_WORKS.md) for the design and the bits that make this possible.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install --save-dev rxjs-leak-finder
|
|
25
|
+
# or
|
|
26
|
+
pnpm add -D rxjs-leak-finder
|
|
27
|
+
# or
|
|
28
|
+
yarn add -D rxjs-leak-finder
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Wire it up
|
|
34
|
+
|
|
35
|
+
Add one block to your Angular `main.ts`. Use `isDevMode()` so it never reaches production:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { isDevMode } from '@angular/core';
|
|
39
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
40
|
+
import { Observable } from 'rxjs';
|
|
41
|
+
import { enableRxjsLeakDetector } from 'rxjs-leak-finder';
|
|
42
|
+
import { AppComponent } from './app/app.component';
|
|
43
|
+
import { appConfig } from './app/app.config';
|
|
44
|
+
|
|
45
|
+
if (isDevMode()) {
|
|
46
|
+
enableRxjsLeakDetector(Observable);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bootstrapApplication(AppComponent, appConfig);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it. Reload — there's a floating widget in the top-right of your app.
|
|
53
|
+
|
|
54
|
+
> Pass **your** `Observable` (the one your app imports from `rxjs`). Different bundles can produce different `Observable` classes; passing yours guarantees the right prototype gets patched.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Use it
|
|
59
|
+
|
|
60
|
+
In one terminal, run your Angular app (`ng serve` / `npm start`).
|
|
61
|
+
|
|
62
|
+
In another terminal, start the dashboard:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx rxjs-leak-finder dashboard
|
|
66
|
+
# → http://localhost:7654
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The dashboard auto-opens in your browser. To record a session:
|
|
70
|
+
|
|
71
|
+
1. In your app: click **● Rec** on the floating widget.
|
|
72
|
+
2. Navigate to the route you want to test.
|
|
73
|
+
3. Navigate **away** to another route (the route change is the "boundary").
|
|
74
|
+
4. Click **■ Stop**.
|
|
75
|
+
5. Reload the dashboard — the session appears in the list. Click it to see the leaks.
|
|
76
|
+
|
|
77
|
+
A leak is any subscription that was created on the route you left, never unsubscribed, and has at least one frame in your own code (framework subscriptions are filtered out).
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## What the dashboard shows
|
|
82
|
+
|
|
83
|
+
- **KPI cards**: total leaks, subscriptions scanned, framework subscriptions ignored, long-lived service subscriptions.
|
|
84
|
+
- **Breakdowns**: by leak kind, by route, by component.
|
|
85
|
+
- **Search**: filter rows by component, file, route, observable kind, leak kind.
|
|
86
|
+
- **Filter chips**: click a kind or a route to narrow down.
|
|
87
|
+
- **Each row**: component name + observable kind on top, `file:line:col` (clickable, opens in your editor) underneath, plus colored badges for route and leak kind.
|
|
88
|
+
- **Expanded row**: full resolved stack trace; click any frame to jump to it in your editor.
|
|
89
|
+
|
|
90
|
+
### Leak kinds
|
|
91
|
+
|
|
92
|
+
| Kind | What triggers it |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `nested-subscribe` | A `subscribe()` was called inside another `subscribe()`. Use `switchMap` / `mergeMap` instead. |
|
|
95
|
+
| `async-init` | `subscribe()` ran after an `await` in an `async ngOnInit` — outside the injection context, so `takeUntilDestroyed()` silently no-ops. |
|
|
96
|
+
| `ng-init` | Plain `subscribe()` in `ngOnInit` with no teardown. |
|
|
97
|
+
| `global-event` | `fromEvent(window, …)` or `addEventListener` — survives navigation because the source outlives the component. |
|
|
98
|
+
| `timer` | `interval` / `timer` subscription with no teardown. |
|
|
99
|
+
| `subject` | Subscribed to a module-level or service-level `Subject` / `BehaviorSubject` without teardown. |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Open in editor
|
|
104
|
+
|
|
105
|
+
Clicking `file:line:col` POSTs to the dashboard server, which shells out to your editor. Default is VS Code (`code -g file:line:col`). To pick another editor:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
RLD_EDITOR=idea npx rxjs-leak-finder dashboard # IntelliJ
|
|
109
|
+
RLD_EDITOR=webstorm npx rxjs-leak-finder dashboard # WebStorm
|
|
110
|
+
RLD_EDITOR=cursor npx rxjs-leak-finder dashboard # Cursor
|
|
111
|
+
RLD_EDITOR=subl npx rxjs-leak-finder dashboard # Sublime
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Recognized: `code`, `cursor`, `codium`, `idea`, `webstorm`, `pycharm`, `phpstorm`, `goland`, `rubymine`, `clion`, `datagrip`, `fleet`, `subl`, `sublime`, `atom`, `vim`, `nvim`, `emacs`. The right CLI flag is picked per editor. If the launcher isn't on PATH, the server falls back to the OS opener (no jump-to-line).
|
|
115
|
+
|
|
116
|
+
To make it permanent: `echo 'export RLD_EDITOR=idea' >> ~/.zshrc`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Config
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
enableRxjsLeakDetector(Observable, {
|
|
124
|
+
/** Don't mount the floating widget. You can still start/stop via the controller. */
|
|
125
|
+
disableWidget: false,
|
|
126
|
+
/** Where the dashboard listens. */
|
|
127
|
+
dashboardUrl: 'http://localhost:7654',
|
|
128
|
+
/** Disable everything (overrides the others). */
|
|
129
|
+
enabled: true,
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The call returns a `LeakDetectorController`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const controller = enableRxjsLeakDetector(Observable);
|
|
137
|
+
controller?.start();
|
|
138
|
+
// …navigate…
|
|
139
|
+
await controller?.stop(); // POSTs the report
|
|
140
|
+
controller?.teardown(); // remove the patch + widget (rarely needed)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## CLI
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
rxjs-leak-finder dashboard [options]
|
|
149
|
+
|
|
150
|
+
--port=<n> Port (default 7654)
|
|
151
|
+
--cwd=<path> Where to write .rld/ session files (default cwd)
|
|
152
|
+
--no-open Don't auto-open the browser
|
|
153
|
+
--help, -h Show this help
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Sessions are stored as `.rld/*.json` in the working directory. Add `.rld/` to `.gitignore`.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## FAQ
|
|
161
|
+
|
|
162
|
+
**Will it break my production build?**
|
|
163
|
+
No — wrap the call in `if (isDevMode())`. The detector still ships in your bundle as a devDependency. The runtime is small (~5 kB gzipped) and inert until `enableRxjsLeakDetector` is called.
|
|
164
|
+
|
|
165
|
+
**Does it work with NgRx, RxJS interop, signals?**
|
|
166
|
+
Yes. It patches the `Observable` prototype, so any subscription created from any observable in your app is tracked. Signals don't create RxJS subscriptions, so they're invisible to the detector — that's correct, since signals can't leak the way subscriptions can.
|
|
167
|
+
|
|
168
|
+
**Does it work in production?**
|
|
169
|
+
Don't run it in production. The detector captures stack traces on every `subscribe()`, which has measurable overhead.
|
|
170
|
+
|
|
171
|
+
**False positives?**
|
|
172
|
+
The biggest source of noise is long-lived service subscriptions (singletons that *should* live for the app's lifetime). The detector lists those separately as `long-lived`, not as leaks. If you see a real subscription marked as a leak that you believe is correct, open an issue with the session JSON from `.rld/`.
|
|
173
|
+
|
|
174
|
+
**No leak shows up?**
|
|
175
|
+
Three common causes:
|
|
176
|
+
1. You didn't click **● Rec** before triggering the leak.
|
|
177
|
+
2. You didn't navigate away (the detector only flags subscriptions on routes you've *left*).
|
|
178
|
+
3. The subscription is in a framework path (`node_modules/`, polyfills, zone.js); those are intentionally filtered.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Architecture in one sentence
|
|
183
|
+
|
|
184
|
+
The detector monkey-patches `Observable.prototype.subscribe` to tag every Subscription with a stack trace, the recorder tracks route changes via the History API, the dashboard server stores reports as JSON, and the dashboard SPA classifies each leak using stack-trace heuristics. For the full story, see [HOW_IT_WORKS.md](./HOW_IT_WORKS.md).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT © Florin Ciocirlan
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ServerHandle = {
|
|
2
|
+
url: string;
|
|
3
|
+
port: number;
|
|
4
|
+
close: () => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export type StartServerOptions = {
|
|
7
|
+
port: number;
|
|
8
|
+
cwd: string;
|
|
9
|
+
staticDir: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
export declare function startServer(opts: StartServerOptions): Promise<ServerHandle>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { mkdirSync, writeFileSync, readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, extname, isAbsolute } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { platform } from 'node:os';
|
|
7
|
+
const MIME = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
};
|
|
14
|
+
export async function startServer(opts) {
|
|
15
|
+
const rldDir = join(opts.cwd, '.rld');
|
|
16
|
+
if (!existsSync(rldDir))
|
|
17
|
+
mkdirSync(rldDir, { recursive: true });
|
|
18
|
+
const server = createServer((req, res) => handleRequest(req, res, opts, rldDir));
|
|
19
|
+
await new Promise((resolveStart) => server.listen(opts.port, () => resolveStart()));
|
|
20
|
+
const addr = server.address();
|
|
21
|
+
const port = typeof addr === 'object' && addr ? addr.port : opts.port;
|
|
22
|
+
return {
|
|
23
|
+
url: `http://localhost:${port}`,
|
|
24
|
+
port,
|
|
25
|
+
close: () => new Promise(r => server.close(() => r())),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function setCors(res) {
|
|
29
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
30
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
31
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
32
|
+
}
|
|
33
|
+
async function handleRequest(req, res, opts, rldDir) {
|
|
34
|
+
setCors(res);
|
|
35
|
+
if (req.method === 'OPTIONS') {
|
|
36
|
+
res.writeHead(204).end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
40
|
+
const pathname = url.pathname;
|
|
41
|
+
if (req.method === 'POST' && pathname === '/report') {
|
|
42
|
+
return handleReport(req, res, rldDir);
|
|
43
|
+
}
|
|
44
|
+
if (req.method === 'POST' && pathname === '/open') {
|
|
45
|
+
return handleOpen(req, res, opts.cwd);
|
|
46
|
+
}
|
|
47
|
+
if (req.method === 'GET' && pathname === '/sessions') {
|
|
48
|
+
return handleSessionsList(res, rldDir);
|
|
49
|
+
}
|
|
50
|
+
if (req.method === 'GET' && pathname.startsWith('/sessions/')) {
|
|
51
|
+
return handleSessionGet(res, rldDir, pathname.slice('/sessions/'.length));
|
|
52
|
+
}
|
|
53
|
+
if (req.method === 'GET' && pathname === '/source-maps') {
|
|
54
|
+
const mapUrl = url.searchParams.get('url');
|
|
55
|
+
const raw = url.searchParams.get('raw') === '1';
|
|
56
|
+
return handleSourceMapProxy(res, mapUrl, raw);
|
|
57
|
+
}
|
|
58
|
+
if (req.method === 'GET' && opts.staticDir) {
|
|
59
|
+
return handleStatic(res, opts.staticDir, pathname);
|
|
60
|
+
}
|
|
61
|
+
res.writeHead(404).end('Not found');
|
|
62
|
+
}
|
|
63
|
+
async function handleOpen(req, res, cwd) {
|
|
64
|
+
const body = await readBody(req);
|
|
65
|
+
let payload;
|
|
66
|
+
try {
|
|
67
|
+
payload = JSON.parse(body);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
res.writeHead(400).end('Invalid JSON');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const resolved = resolveSourcePath(payload.file ?? '', cwd);
|
|
74
|
+
if (!resolved) {
|
|
75
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
76
|
+
res.end(JSON.stringify({ ok: false, error: `Could not resolve ${payload.file}` }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const line = Math.max(1, Number(payload.line) || 1);
|
|
80
|
+
const column = Math.max(1, Number(payload.column) || 1);
|
|
81
|
+
const opened = await openInEditor(resolved, line, column);
|
|
82
|
+
res.writeHead(opened.ok ? 200 : 500, { 'content-type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(opened));
|
|
84
|
+
}
|
|
85
|
+
function resolveSourcePath(rawFile, cwd) {
|
|
86
|
+
if (!rawFile)
|
|
87
|
+
return null;
|
|
88
|
+
// Strip common bundler prefixes that show up in source-mapped paths.
|
|
89
|
+
let f = rawFile
|
|
90
|
+
.replace(/^webpack:\/\/\/?/, '')
|
|
91
|
+
.replace(/^vite:\/?/, '')
|
|
92
|
+
.replace(/^\.\//, '');
|
|
93
|
+
// Some bundles prefix with the project name: `./projectName/src/...`
|
|
94
|
+
if (isAbsolute(f) && existsSync(f))
|
|
95
|
+
return f;
|
|
96
|
+
const candidates = [
|
|
97
|
+
resolve(cwd, f),
|
|
98
|
+
resolve(cwd, 'src', f),
|
|
99
|
+
resolve(cwd, f.replace(/^[^/]+\//, '')), // strip first path segment
|
|
100
|
+
];
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
if (existsSync(c))
|
|
103
|
+
return c;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function buildEditorCommand(absPath, line, column) {
|
|
108
|
+
const editor = (process.env.RLD_EDITOR || 'code').trim();
|
|
109
|
+
// Last path segment in case RLD_EDITOR points to a full path like /usr/local/bin/idea
|
|
110
|
+
const bin = editor.split('/').pop().toLowerCase();
|
|
111
|
+
// JetBrains IDEs: `idea --line <n> --column <n> <file>` (or `webstorm`, `pycharm`, etc.)
|
|
112
|
+
if (/^(idea|webstorm|pycharm|rubymine|phpstorm|goland|rustrover|clion|appcode|datagrip|fleet)$/.test(bin)) {
|
|
113
|
+
return { cmd: editor, args: ['--line', String(line), '--column', String(column), absPath] };
|
|
114
|
+
}
|
|
115
|
+
// Sublime Text / Atom: `subl file:line:col`
|
|
116
|
+
if (/^(subl|sublime|atom)$/.test(bin)) {
|
|
117
|
+
return { cmd: editor, args: [`${absPath}:${line}:${column}`] };
|
|
118
|
+
}
|
|
119
|
+
// Vim / Neovim / Emacs (terminal — works if user has a terminal available):
|
|
120
|
+
if (/^(vim|nvim)$/.test(bin)) {
|
|
121
|
+
return { cmd: editor, args: [`+${line}`, absPath] };
|
|
122
|
+
}
|
|
123
|
+
if (/^emacs$/.test(bin)) {
|
|
124
|
+
return { cmd: editor, args: [`+${line}:${column}`, absPath] };
|
|
125
|
+
}
|
|
126
|
+
// Default: VS Code / Cursor / Codium — all accept `code -g file:line:col`.
|
|
127
|
+
return { cmd: editor, args: ['-g', `${absPath}:${line}:${column}`] };
|
|
128
|
+
}
|
|
129
|
+
function openInEditor(absPath, line, column) {
|
|
130
|
+
return new Promise((resolveP) => {
|
|
131
|
+
const { cmd, args } = buildEditorCommand(absPath, line, column);
|
|
132
|
+
const fallback = () => {
|
|
133
|
+
const os = platform();
|
|
134
|
+
const openerCmd = os === 'darwin' ? 'open' : os === 'win32' ? 'explorer' : 'xdg-open';
|
|
135
|
+
const child = spawn(openerCmd, [absPath], { stdio: 'ignore', detached: true });
|
|
136
|
+
child.on('error', (err) => resolveP({ ok: false, error: err.message }));
|
|
137
|
+
child.on('spawn', () => { child.unref(); resolveP({ ok: true, cmd: openerCmd }); });
|
|
138
|
+
};
|
|
139
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
140
|
+
child.on('error', () => fallback());
|
|
141
|
+
child.on('spawn', () => { child.unref(); resolveP({ ok: true, cmd: `${cmd} ${args.join(' ')}` }); });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function handleReport(req, res, rldDir) {
|
|
145
|
+
const body = await readBody(req);
|
|
146
|
+
let report;
|
|
147
|
+
try {
|
|
148
|
+
report = JSON.parse(body);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
res.writeHead(400).end('Invalid JSON');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const recordingId = report?.meta?.recordingId ?? randomUUID();
|
|
155
|
+
const fileName = `${new Date().toISOString().replace(/[:.]/g, '-')}-${recordingId}.json`;
|
|
156
|
+
writeFileSync(join(rldDir, fileName), JSON.stringify(report, null, 2));
|
|
157
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ ok: true, fileName }));
|
|
159
|
+
}
|
|
160
|
+
function handleSessionsList(res, rldDir) {
|
|
161
|
+
const files = readdirSync(rldDir).filter(f => f.endsWith('.json'));
|
|
162
|
+
const list = files.map(f => {
|
|
163
|
+
const parsed = JSON.parse(readFileSync(join(rldDir, f), 'utf8'));
|
|
164
|
+
return {
|
|
165
|
+
id: f.replace(/\.json$/, ''),
|
|
166
|
+
fileName: f,
|
|
167
|
+
recordingId: parsed?.meta?.recordingId ?? null,
|
|
168
|
+
createdAt: parsed?.meta?.startedAtMs ?? null,
|
|
169
|
+
subscriptionCount: parsed?.subscriptions?.length ?? 0,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
173
|
+
res.end(JSON.stringify(list));
|
|
174
|
+
}
|
|
175
|
+
function handleSessionGet(res, rldDir, id) {
|
|
176
|
+
const safe = id.replace(/[^a-zA-Z0-9._-]/g, '');
|
|
177
|
+
const file = join(rldDir, `${safe}.json`);
|
|
178
|
+
if (!existsSync(file)) {
|
|
179
|
+
res.writeHead(404).end('Not found');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
183
|
+
res.end(readFileSync(file));
|
|
184
|
+
}
|
|
185
|
+
async function handleSourceMapProxy(res, mapUrl, raw = false) {
|
|
186
|
+
if (!mapUrl) {
|
|
187
|
+
res.writeHead(400).end('Missing url param');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const upstream = await fetch(mapUrl);
|
|
192
|
+
if (!upstream.ok) {
|
|
193
|
+
console.log(`[source-maps] ${mapUrl} → ${upstream.status}`);
|
|
194
|
+
res.writeHead(upstream.status).end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const text = await upstream.text();
|
|
198
|
+
if (raw) {
|
|
199
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
200
|
+
res.end(text);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
204
|
+
res.end(text);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
208
|
+
console.log(`[source-maps] ${mapUrl} → fetch threw: ${msg}`);
|
|
209
|
+
res.writeHead(502).end(msg);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function handleStatic(res, staticDir, pathname) {
|
|
213
|
+
const filePath = resolve(staticDir, pathname === '/' ? 'index.html' : pathname.slice(1));
|
|
214
|
+
if (!filePath.startsWith(resolve(staticDir))) {
|
|
215
|
+
res.writeHead(403).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
219
|
+
// SPA fallback to index.html
|
|
220
|
+
const index = resolve(staticDir, 'index.html');
|
|
221
|
+
if (existsSync(index)) {
|
|
222
|
+
res.writeHead(200, { 'content-type': MIME['.html'] });
|
|
223
|
+
res.end(readFileSync(index));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
res.writeHead(404).end();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const ext = extname(filePath).toLowerCase();
|
|
230
|
+
res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream' });
|
|
231
|
+
res.end(readFileSync(filePath));
|
|
232
|
+
}
|
|
233
|
+
function readBody(req) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
let data = '';
|
|
236
|
+
req.on('data', chunk => { data += chunk; });
|
|
237
|
+
req.on('end', () => resolve(data));
|
|
238
|
+
req.on('error', reject);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=dashboard-server.js.map
|