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.
Files changed (37) hide show
  1. package/HOW_IT_WORKS.md +214 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/dist/cli/dashboard-server.d.ts +11 -0
  5. package/dist/cli/dashboard-server.js +241 -0
  6. package/dist/cli/dashboard-server.js.map +1 -0
  7. package/dist/cli/index.d.ts +2 -0
  8. package/dist/cli/index.js +67 -0
  9. package/dist/cli/index.js.map +1 -0
  10. package/dist/dashboard/assets/index-BqI1esY6.js +350 -0
  11. package/dist/dashboard/assets/mappings-Dhu04MXZ.wasm +0 -0
  12. package/dist/dashboard/index.html +25 -0
  13. package/dist/runtime/enable.d.ts +29 -0
  14. package/dist/runtime/enable.js +60 -0
  15. package/dist/runtime/enable.js.map +1 -0
  16. package/dist/runtime/index.d.ts +2 -0
  17. package/dist/runtime/index.js +2 -0
  18. package/dist/runtime/index.js.map +1 -0
  19. package/dist/runtime/patch.d.ts +11 -0
  20. package/dist/runtime/patch.js +42 -0
  21. package/dist/runtime/patch.js.map +1 -0
  22. package/dist/runtime/recorder.d.ts +15 -0
  23. package/dist/runtime/recorder.js +94 -0
  24. package/dist/runtime/recorder.js.map +1 -0
  25. package/dist/runtime/route-tracker.d.ts +8 -0
  26. package/dist/runtime/route-tracker.js +130 -0
  27. package/dist/runtime/route-tracker.js.map +1 -0
  28. package/dist/runtime/transport.d.ts +12 -0
  29. package/dist/runtime/transport.js +85 -0
  30. package/dist/runtime/transport.js.map +1 -0
  31. package/dist/runtime/types.d.ts +96 -0
  32. package/dist/runtime/types.js +7 -0
  33. package/dist/runtime/types.js.map +1 -0
  34. package/dist/runtime/widget.d.ts +11 -0
  35. package/dist/runtime/widget.js +61 -0
  36. package/dist/runtime/widget.js.map +1 -0
  37. package/package.json +61 -0
@@ -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
+ [![npm](https://img.shields.io/npm/v/rxjs-leak-finder.svg)](https://www.npmjs.com/package/rxjs-leak-finder)
7
+ [![license](https://img.shields.io/npm/l/rxjs-leak-finder.svg)](./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