@nest-rn-lens/nest 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +198 -2
- package/dist/cjs/constants.d.ts +4 -0
- package/dist/cjs/constants.js +7 -0
- package/dist/cjs/http.d.ts +35 -0
- package/dist/cjs/http.js +46 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +9 -0
- package/dist/cjs/interceptor.d.ts +20 -0
- package/dist/cjs/interceptor.js +100 -0
- package/dist/cjs/module.d.ts +13 -0
- package/dist/cjs/module.js +44 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/reporter.d.ts +12 -0
- package/dist/cjs/reporter.js +78 -0
- package/dist/cjs/types.d.ts +62 -0
- package/dist/cjs/types.js +15 -0
- package/dist/esm/constants.d.ts +4 -0
- package/dist/esm/constants.js +4 -0
- package/dist/esm/http.d.ts +35 -0
- package/dist/esm/http.js +39 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/interceptor.d.ts +20 -0
- package/dist/esm/interceptor.js +97 -0
- package/dist/esm/module.d.ts +13 -0
- package/dist/esm/module.js +41 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/reporter.d.ts +12 -0
- package/dist/esm/reporter.js +75 -0
- package/dist/esm/types.d.ts +62 -0
- package/dist/esm/types.js +12 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 isa
|
|
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
CHANGED
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nest-rn-lens/nest
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**See which React Native screen made each request to your NestJS API.**
|
|
4
|
+
|
|
5
|
+
A NestJS interceptor that records every request your API handles: the app and
|
|
6
|
+
the exact file and line that sent it, the endpoint and the handler that answered,
|
|
7
|
+
the status, and the time it took.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
[NestRnLens] mobile (ios) apps/mobile/src/screens/order-details.tsx:23 → GET /orders/:id → OrdersController.findOne 200 4ms
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It is the server half of **NestRN Lens**, a toolkit for Turborepo monorepos with
|
|
14
|
+
a NestJS API and a React Native (Expo) app. On its own it gives you readable
|
|
15
|
+
request logs and a typed event for every call. With the NestRN Lens VS Code
|
|
16
|
+
extension, those events become a live traffic panel where you can click any
|
|
17
|
+
request to open the screen that made it or the handler that answered it.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @nest-rn-lens/nest
|
|
23
|
+
# or
|
|
24
|
+
pnpm add @nest-rn-lens/nest
|
|
25
|
+
yarn add @nest-rn-lens/nest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
Any NestJS API that serves React Native (or web) clients works. There's nothing
|
|
31
|
+
specific to one project.
|
|
32
|
+
|
|
33
|
+
| Requirement | Supported |
|
|
34
|
+
| ---------------- | ----------------------------------------------- |
|
|
35
|
+
| NestJS | 10, 11 or 12 |
|
|
36
|
+
| RxJS | 7 |
|
|
37
|
+
| HTTP platform | Express (default) or Fastify |
|
|
38
|
+
| Module system | ES modules or CommonJS |
|
|
39
|
+
| Node.js | 18+, or what your NestJS version requires |
|
|
40
|
+
|
|
41
|
+
A Turborepo isn't required for the interceptor itself. It's only needed for the
|
|
42
|
+
NestRN Lens VS Code extension, which finds your API and app inside the monorepo.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Import the module once, in your root module:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Module } from '@nestjs/common';
|
|
50
|
+
import { NestRnLensModule } from '@nest-rn-lens/nest';
|
|
51
|
+
|
|
52
|
+
@Module({
|
|
53
|
+
imports: [NestRnLensModule.forRoot({ app: 'api' })],
|
|
54
|
+
})
|
|
55
|
+
export class AppModule {}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's it. Every request handled by a controller is now logged:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
[NestRnLens] unknown (android) → GET /orders → OrdersController.findAll 200 3ms
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requests show `unknown` until the client says who it is. The next section
|
|
65
|
+
explains how.
|
|
66
|
+
|
|
67
|
+
## Telling the API who's calling
|
|
68
|
+
|
|
69
|
+
A request on its own doesn't say which app or which screen sent it. The client
|
|
70
|
+
adds that with three headers:
|
|
71
|
+
|
|
72
|
+
| Header | Example | Used for |
|
|
73
|
+
| ------------------------- | ----------------------------------------------- | ------------------------------------------------- |
|
|
74
|
+
| `x-nest-rn-lens-app` | `mobile` | Which app made the request |
|
|
75
|
+
| `x-nest-rn-lens-caller` | `/repo/apps/mobile/src/screens/orders.tsx:23` | The file and line that made it |
|
|
76
|
+
| `x-nest-rn-lens-trace-id` | `5f0c…` | Linking every hop of one request chain |
|
|
77
|
+
|
|
78
|
+
You don't have to write these by hand. The upcoming React Native client,
|
|
79
|
+
`@nest-rn-lens/react-native`, wraps `fetch` and fills them in development,
|
|
80
|
+
working out the calling file from the stack trace.
|
|
81
|
+
|
|
82
|
+
When a header is missing, the interceptor still reports the request:
|
|
83
|
+
|
|
84
|
+
- the app is `unknown`
|
|
85
|
+
- the platform is guessed from the `User-Agent` (`okhttp` → Android,
|
|
86
|
+
`CFNetwork` → iOS, a browser → web)
|
|
87
|
+
- a new trace id is created
|
|
88
|
+
|
|
89
|
+
The trace id is always sent back in the `x-nest-rn-lens-trace-id` response
|
|
90
|
+
header, so the client or the next service can reuse it.
|
|
91
|
+
|
|
92
|
+
The header names are exported, so you can use them in your own code:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { NEST_RN_LENS_HEADERS } from '@nest-rn-lens/nest';
|
|
96
|
+
|
|
97
|
+
fetch(url, { headers: { [NEST_RN_LENS_HEADERS.app]: 'admin-dashboard' } });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Options
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
NestRnLensModule.forRoot({
|
|
104
|
+
app: 'api',
|
|
105
|
+
enabled: true,
|
|
106
|
+
log: true,
|
|
107
|
+
onEvent: (event) => {},
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Option | Type | Default | Description |
|
|
112
|
+
| --------- | --------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
113
|
+
| `app` | `string` | required | Name of this API in logs and in the traffic graph. |
|
|
114
|
+
| `enabled` | `boolean` | `true` unless `NODE_ENV=production` | Turns the interceptor on or off. When off, requests pass through untouched. |
|
|
115
|
+
| `log` | `boolean` | `true` | Logs a readable line per request, plus the full event as JSON at debug level. |
|
|
116
|
+
| `onEvent` | `(event: NestRnLensEvent) => void` | none | Called with every event. Forward traffic to your own tooling, tests or metrics. |
|
|
117
|
+
|
|
118
|
+
### The event
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
interface NestRnLensEvent {
|
|
122
|
+
id: string;
|
|
123
|
+
traceId: string;
|
|
124
|
+
timestamp: number; // ms since epoch, when the response was ready
|
|
125
|
+
durationMs: number;
|
|
126
|
+
source: {
|
|
127
|
+
app: string; // "mobile", or "unknown"
|
|
128
|
+
caller?: string; // "/repo/apps/mobile/src/screens/orders.tsx:23"
|
|
129
|
+
platform: 'ios' | 'android' | 'web' | 'unknown';
|
|
130
|
+
};
|
|
131
|
+
target: {
|
|
132
|
+
app: string; // your `app` option
|
|
133
|
+
method: string; // "GET"
|
|
134
|
+
path: string; // "/orders/42" (no query string)
|
|
135
|
+
route: string; // "/orders/:id"
|
|
136
|
+
controller: string; // "OrdersController"
|
|
137
|
+
handler: string; // "findOne"
|
|
138
|
+
};
|
|
139
|
+
status: number; // 200, 201, 404, 500…
|
|
140
|
+
error?: string; // exception message, when the handler threw
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`route` is the route pattern, not the URL, so all calls to one endpoint group
|
|
145
|
+
together. `status` follows Nest's rules: `@HttpCode()` when set, `201` for
|
|
146
|
+
`POST`, the exception's status when a handler throws, and `500` for other errors.
|
|
147
|
+
|
|
148
|
+
## Safe by design
|
|
149
|
+
|
|
150
|
+
NestRN Lens is a development tool. It is built so that it can't hurt the API it
|
|
151
|
+
watches:
|
|
152
|
+
|
|
153
|
+
- **Off in production by default.** Unless you pass `enabled: true`, nothing
|
|
154
|
+
runs when `NODE_ENV=production`.
|
|
155
|
+
- **Never breaks a request.** If your `onEvent` callback or the logger throws,
|
|
156
|
+
the error is swallowed and the response goes out as usual.
|
|
157
|
+
- **Doesn't change responses.** The only thing it adds is the trace id header.
|
|
158
|
+
- **HTTP only.** Microservice, WebSocket and GraphQL contexts pass straight
|
|
159
|
+
through.
|
|
160
|
+
|
|
161
|
+
## Browsers and CORS
|
|
162
|
+
|
|
163
|
+
Requests from a web build carry custom headers, so the browser sends a
|
|
164
|
+
preflight request first. Enable CORS in development:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const app = await NestFactory.create(AppModule);
|
|
168
|
+
app.enableCors({ exposedHeaders: ['x-nest-rn-lens-trace-id'] });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`exposedHeaders` is only needed if browser code reads the trace id from the
|
|
172
|
+
response. Native React Native apps don't use CORS at all.
|
|
173
|
+
|
|
174
|
+
## Limitations
|
|
175
|
+
|
|
176
|
+
- Only requests that reach a controller are reported. A request to a route that
|
|
177
|
+
doesn't exist gets a 404 from Nest before any interceptor runs.
|
|
178
|
+
- `durationMs` is measured inside Nest, from the interceptor until the handler
|
|
179
|
+
returns. It doesn't include network time or response serialization.
|
|
180
|
+
|
|
181
|
+
## NestRN Lens packages
|
|
182
|
+
|
|
183
|
+
| Package | What it does | Status |
|
|
184
|
+
| ----------------------------- | ----------------------------------------------------------------- | ------------- |
|
|
185
|
+
| `@nest-rn-lens/nest` | This interceptor | Available |
|
|
186
|
+
| `@nest-rn-lens/react-native` | `fetch` wrapper that sends the app name and the calling screen | Coming soon |
|
|
187
|
+
| NestRN Lens for VS Code | Live traffic panel with the app running in a phone frame | Coming soon |
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm install
|
|
193
|
+
npm test # builds the tests and runs them on Express and Fastify
|
|
194
|
+
npm run build # ES module and CommonJS builds in dist/
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOGGER_CONTEXT = exports.NEST_RN_LENS_OPTIONS = void 0;
|
|
4
|
+
/** Injection token for the resolved module options. */
|
|
5
|
+
exports.NEST_RN_LENS_OPTIONS = Symbol('NEST_RN_LENS_OPTIONS');
|
|
6
|
+
/** Logger context. The VS Code extension looks for "[NestRnLens] {json}" lines. */
|
|
7
|
+
exports.LOGGER_CONTEXT = 'NestRnLens';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Platform } from './types.js';
|
|
2
|
+
export interface HttpRequest {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
originalUrl?: string;
|
|
6
|
+
headers: Record<string, string | string[] | undefined>;
|
|
7
|
+
/** Express: the matched route. */
|
|
8
|
+
route?: {
|
|
9
|
+
path?: unknown;
|
|
10
|
+
};
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Fastify 4+: the matched route. */
|
|
13
|
+
routeOptions?: {
|
|
14
|
+
url?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Fastify 3. */
|
|
17
|
+
routerPath?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface HttpResponse {
|
|
20
|
+
statusCode: number;
|
|
21
|
+
/** Express (Node's ServerResponse). */
|
|
22
|
+
setHeader?(name: string, value: string): unknown;
|
|
23
|
+
/** Fastify reply. */
|
|
24
|
+
header?(name: string, value: string): unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function readHeader(req: HttpRequest, name: string): string | undefined;
|
|
27
|
+
export declare function writeHeader(res: HttpResponse, name: string, value: string): void;
|
|
28
|
+
export declare function requestPath(req: HttpRequest): string;
|
|
29
|
+
/** "/orders/:id" rather than "/orders/42", so one endpoint is one graph edge. */
|
|
30
|
+
export declare function routePattern(req: HttpRequest): string;
|
|
31
|
+
/**
|
|
32
|
+
* Best guess when the client doesn't say. React Native's fetch uses okhttp on
|
|
33
|
+
* Android and CFNetwork on iOS; browsers send a Mozilla user agent.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectPlatform(userAgent?: string): Platform;
|
package/dist/cjs/http.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readHeader = readHeader;
|
|
4
|
+
exports.writeHeader = writeHeader;
|
|
5
|
+
exports.requestPath = requestPath;
|
|
6
|
+
exports.routePattern = routePattern;
|
|
7
|
+
exports.detectPlatform = detectPlatform;
|
|
8
|
+
function readHeader(req, name) {
|
|
9
|
+
const value = req.headers[name];
|
|
10
|
+
return Array.isArray(value) ? value[0] : value;
|
|
11
|
+
}
|
|
12
|
+
function writeHeader(res, name, value) {
|
|
13
|
+
if (typeof res.header === 'function') {
|
|
14
|
+
res.header(name, value);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
res.setHeader?.(name, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function requestPath(req) {
|
|
21
|
+
return (req.originalUrl ?? req.url).split('?')[0];
|
|
22
|
+
}
|
|
23
|
+
/** "/orders/:id" rather than "/orders/42", so one endpoint is one graph edge. */
|
|
24
|
+
function routePattern(req) {
|
|
25
|
+
const expressRoute = req.route?.path;
|
|
26
|
+
if (typeof expressRoute === 'string') {
|
|
27
|
+
return `${req.baseUrl ?? ''}${expressRoute}`;
|
|
28
|
+
}
|
|
29
|
+
return req.routeOptions?.url ?? req.routerPath ?? requestPath(req);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Best guess when the client doesn't say. React Native's fetch uses okhttp on
|
|
33
|
+
* Android and CFNetwork on iOS; browsers send a Mozilla user agent.
|
|
34
|
+
*/
|
|
35
|
+
function detectPlatform(userAgent = '') {
|
|
36
|
+
if (/okhttp|android/i.test(userAgent)) {
|
|
37
|
+
return 'android';
|
|
38
|
+
}
|
|
39
|
+
if (/CFNetwork|Darwin|iPhone|iPad/i.test(userAgent)) {
|
|
40
|
+
return 'ios';
|
|
41
|
+
}
|
|
42
|
+
if (/Mozilla/i.test(userAgent)) {
|
|
43
|
+
return 'web';
|
|
44
|
+
}
|
|
45
|
+
return 'unknown';
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NEST_RN_LENS_HEADERS = exports.NestRnLensInterceptor = exports.NestRnLensModule = void 0;
|
|
4
|
+
var module_js_1 = require("./module.js");
|
|
5
|
+
Object.defineProperty(exports, "NestRnLensModule", { enumerable: true, get: function () { return module_js_1.NestRnLensModule; } });
|
|
6
|
+
var interceptor_js_1 = require("./interceptor.js");
|
|
7
|
+
Object.defineProperty(exports, "NestRnLensInterceptor", { enumerable: true, get: function () { return interceptor_js_1.NestRnLensInterceptor; } });
|
|
8
|
+
var types_js_1 = require("./types.js");
|
|
9
|
+
Object.defineProperty(exports, "NEST_RN_LENS_HEADERS", { enumerable: true, get: function () { return types_js_1.NEST_RN_LENS_HEADERS; } });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { type Observable } from 'rxjs';
|
|
3
|
+
import { NestRnLensReporter } from './reporter.js';
|
|
4
|
+
import { type NestRnLensOptions } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Records every HTTP request handled by a controller: who called it (from the
|
|
7
|
+
* x-nest-rn-lens-* headers), which handler answered, the status and the time.
|
|
8
|
+
*/
|
|
9
|
+
export declare class NestRnLensInterceptor implements NestInterceptor {
|
|
10
|
+
private readonly options;
|
|
11
|
+
private readonly reporter;
|
|
12
|
+
constructor(options: NestRnLensOptions, reporter: NestRnLensReporter);
|
|
13
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* The status Nest is about to send. Depending on the platform it may not be
|
|
16
|
+
* written to the response yet, so follow Nest's own rules: @HttpCode(),
|
|
17
|
+
* otherwise 201 for POST and 200 for everything else.
|
|
18
|
+
*/
|
|
19
|
+
private successStatus;
|
|
20
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.NestRnLensInterceptor = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const rxjs_1 = require("rxjs");
|
|
19
|
+
const constants_js_1 = require("./constants.js");
|
|
20
|
+
const http_js_1 = require("./http.js");
|
|
21
|
+
const reporter_js_1 = require("./reporter.js");
|
|
22
|
+
const types_js_1 = require("./types.js");
|
|
23
|
+
// Metadata key behind @HttpCode() (HTTP_CODE_METADATA in @nestjs/common/constants,
|
|
24
|
+
// unchanged since Nest 5). Inlined because that subpath resolves differently
|
|
25
|
+
// across Nest versions.
|
|
26
|
+
const HTTP_CODE_METADATA = '__httpCode__';
|
|
27
|
+
/**
|
|
28
|
+
* Records every HTTP request handled by a controller: who called it (from the
|
|
29
|
+
* x-nest-rn-lens-* headers), which handler answered, the status and the time.
|
|
30
|
+
*/
|
|
31
|
+
let NestRnLensInterceptor = class NestRnLensInterceptor {
|
|
32
|
+
options;
|
|
33
|
+
reporter;
|
|
34
|
+
constructor(options, reporter) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.reporter = reporter;
|
|
37
|
+
}
|
|
38
|
+
intercept(context, next) {
|
|
39
|
+
if (!this.options.enabled || context.getType() !== 'http') {
|
|
40
|
+
return next.handle();
|
|
41
|
+
}
|
|
42
|
+
const http = context.switchToHttp();
|
|
43
|
+
const req = http.getRequest();
|
|
44
|
+
const res = http.getResponse();
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
const traceId = (0, http_js_1.readHeader)(req, types_js_1.NEST_RN_LENS_HEADERS.traceId) ?? (0, node_crypto_1.randomUUID)();
|
|
47
|
+
// Lets the client, and the next service in the chain, reuse the trace id.
|
|
48
|
+
(0, http_js_1.writeHeader)(res, types_js_1.NEST_RN_LENS_HEADERS.traceId, traceId);
|
|
49
|
+
const finish = (status, error) => {
|
|
50
|
+
const event = {
|
|
51
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
52
|
+
traceId,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
durationMs: Math.round(performance.now() - start),
|
|
55
|
+
source: {
|
|
56
|
+
app: (0, http_js_1.readHeader)(req, types_js_1.NEST_RN_LENS_HEADERS.app) ?? 'unknown',
|
|
57
|
+
caller: (0, http_js_1.readHeader)(req, types_js_1.NEST_RN_LENS_HEADERS.caller),
|
|
58
|
+
platform: (0, http_js_1.detectPlatform)((0, http_js_1.readHeader)(req, 'user-agent')),
|
|
59
|
+
},
|
|
60
|
+
target: {
|
|
61
|
+
app: this.options.app,
|
|
62
|
+
method: req.method,
|
|
63
|
+
path: (0, http_js_1.requestPath)(req),
|
|
64
|
+
route: (0, http_js_1.routePattern)(req),
|
|
65
|
+
controller: context.getClass().name,
|
|
66
|
+
handler: context.getHandler().name,
|
|
67
|
+
},
|
|
68
|
+
status,
|
|
69
|
+
error,
|
|
70
|
+
};
|
|
71
|
+
this.reporter.report(event);
|
|
72
|
+
};
|
|
73
|
+
return next.handle().pipe((0, rxjs_1.tap)({
|
|
74
|
+
next: () => finish(this.successStatus(context, req, res)),
|
|
75
|
+
error: (err) => finish(err instanceof common_1.HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err)),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The status Nest is about to send. Depending on the platform it may not be
|
|
80
|
+
* written to the response yet, so follow Nest's own rules: @HttpCode(),
|
|
81
|
+
* otherwise 201 for POST and 200 for everything else.
|
|
82
|
+
*/
|
|
83
|
+
successStatus(context, req, res) {
|
|
84
|
+
const declared = Reflect.getMetadata(HTTP_CODE_METADATA, context.getHandler());
|
|
85
|
+
if (declared) {
|
|
86
|
+
return declared;
|
|
87
|
+
}
|
|
88
|
+
// A handler using @Res() may have set its own status.
|
|
89
|
+
if (res.statusCode && res.statusCode !== 200) {
|
|
90
|
+
return res.statusCode;
|
|
91
|
+
}
|
|
92
|
+
return req.method === 'POST' ? 201 : 200;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
exports.NestRnLensInterceptor = NestRnLensInterceptor;
|
|
96
|
+
exports.NestRnLensInterceptor = NestRnLensInterceptor = __decorate([
|
|
97
|
+
(0, common_1.Injectable)(),
|
|
98
|
+
__param(0, (0, common_1.Inject)(constants_js_1.NEST_RN_LENS_OPTIONS)),
|
|
99
|
+
__metadata("design:paramtypes", [Object, reporter_js_1.NestRnLensReporter])
|
|
100
|
+
], NestRnLensInterceptor);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
import type { NestRnLensOptions } from './types.js';
|
|
3
|
+
export declare class NestRnLensModule {
|
|
4
|
+
/**
|
|
5
|
+
* Registers the interceptor for every controller in the app.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* @Module({ imports: [NestRnLensModule.forRoot({ app: 'api' })] })
|
|
9
|
+
* export class AppModule {}
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
static forRoot(options: NestRnLensOptions): DynamicModule;
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var NestRnLensModule_1;
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.NestRnLensModule = void 0;
|
|
11
|
+
const common_1 = require("@nestjs/common");
|
|
12
|
+
const core_1 = require("@nestjs/core");
|
|
13
|
+
const constants_js_1 = require("./constants.js");
|
|
14
|
+
const interceptor_js_1 = require("./interceptor.js");
|
|
15
|
+
const reporter_js_1 = require("./reporter.js");
|
|
16
|
+
let NestRnLensModule = NestRnLensModule_1 = class NestRnLensModule {
|
|
17
|
+
/**
|
|
18
|
+
* Registers the interceptor for every controller in the app.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* @Module({ imports: [NestRnLensModule.forRoot({ app: 'api' })] })
|
|
22
|
+
* export class AppModule {}
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
static forRoot(options) {
|
|
26
|
+
const resolved = {
|
|
27
|
+
...options,
|
|
28
|
+
enabled: options.enabled ?? process.env.NODE_ENV !== 'production',
|
|
29
|
+
log: options.log ?? true,
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
module: NestRnLensModule_1,
|
|
33
|
+
providers: [
|
|
34
|
+
{ provide: constants_js_1.NEST_RN_LENS_OPTIONS, useValue: resolved },
|
|
35
|
+
reporter_js_1.NestRnLensReporter,
|
|
36
|
+
{ provide: core_1.APP_INTERCEPTOR, useClass: interceptor_js_1.NestRnLensInterceptor },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
exports.NestRnLensModule = NestRnLensModule;
|
|
42
|
+
exports.NestRnLensModule = NestRnLensModule = NestRnLensModule_1 = __decorate([
|
|
43
|
+
(0, common_1.Module)({})
|
|
44
|
+
], NestRnLensModule);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { NestRnLensEvent, NestRnLensOptions } from './types.js';
|
|
2
|
+
/** Sends each event to the logger and to the `onEvent` callback. Never throws. */
|
|
3
|
+
export declare class NestRnLensReporter {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private repoRoot?;
|
|
7
|
+
constructor(options: NestRnLensOptions);
|
|
8
|
+
report(event: NestRnLensEvent): void;
|
|
9
|
+
/** "mobile (ios) apps/mobile/src/screens/orders.tsx:17 → GET /orders → OrdersController.findAll 200 4ms" */
|
|
10
|
+
private describe;
|
|
11
|
+
private shortPath;
|
|
12
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.NestRnLensReporter = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const constants_js_1 = require("./constants.js");
|
|
20
|
+
/** Sends each event to the logger and to the `onEvent` callback. Never throws. */
|
|
21
|
+
let NestRnLensReporter = class NestRnLensReporter {
|
|
22
|
+
options;
|
|
23
|
+
logger = new common_1.Logger(constants_js_1.LOGGER_CONTEXT);
|
|
24
|
+
repoRoot;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
report(event) {
|
|
29
|
+
try {
|
|
30
|
+
this.options.onEvent?.(event);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A broken callback must not break the request it observes.
|
|
34
|
+
}
|
|
35
|
+
if (this.options.log === false) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
this.logger.log(this.describe(event));
|
|
40
|
+
this.logger.debug(JSON.stringify(event));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Same for a broken logger.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** "mobile (ios) apps/mobile/src/screens/orders.tsx:17 → GET /orders → OrdersController.findAll 200 4ms" */
|
|
47
|
+
describe({ source, target, status, durationMs }) {
|
|
48
|
+
const from = [`${source.app} (${source.platform})`, this.shortPath(source.caller)].filter(Boolean).join(' ');
|
|
49
|
+
return `${from} → ${target.method} ${target.route} → ${target.controller}.${target.handler} ${status} ${durationMs}ms`;
|
|
50
|
+
}
|
|
51
|
+
// Clients send absolute paths (editors need them to open the file); logs are
|
|
52
|
+
// easier to read relative to the monorepo root.
|
|
53
|
+
shortPath(file) {
|
|
54
|
+
if (!file?.startsWith('/')) {
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
this.repoRoot ??= findRepoRoot(process.cwd());
|
|
58
|
+
return (0, node_path_1.relative)(this.repoRoot, file);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
exports.NestRnLensReporter = NestRnLensReporter;
|
|
62
|
+
exports.NestRnLensReporter = NestRnLensReporter = __decorate([
|
|
63
|
+
(0, common_1.Injectable)(),
|
|
64
|
+
__param(0, (0, common_1.Inject)(constants_js_1.NEST_RN_LENS_OPTIONS)),
|
|
65
|
+
__metadata("design:paramtypes", [Object])
|
|
66
|
+
], NestRnLensReporter);
|
|
67
|
+
const ROOT_MARKERS = ['turbo.json', 'pnpm-workspace.yaml', '.git'];
|
|
68
|
+
function findRepoRoot(from) {
|
|
69
|
+
let dir = from;
|
|
70
|
+
while (!ROOT_MARKERS.some((marker) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, marker)))) {
|
|
71
|
+
const parent = (0, node_path_1.dirname)(dir);
|
|
72
|
+
if (parent === dir) {
|
|
73
|
+
return from;
|
|
74
|
+
}
|
|
75
|
+
dir = parent;
|
|
76
|
+
}
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers a client sends so the interceptor knows who made the request.
|
|
3
|
+
* The React Native client (@nest-rn-lens/react-native) fills them in dev.
|
|
4
|
+
*/
|
|
5
|
+
export declare const NEST_RN_LENS_HEADERS: {
|
|
6
|
+
/** Name of the calling app, e.g. "mobile". */
|
|
7
|
+
readonly app: "x-nest-rn-lens-app";
|
|
8
|
+
/** Where in the client the call was made, e.g. "/…/src/screens/OrderList.tsx:42". */
|
|
9
|
+
readonly caller: "x-nest-rn-lens-caller";
|
|
10
|
+
/** Same id across every hop of one request chain. Echoed back on the response. */
|
|
11
|
+
readonly traceId: "x-nest-rn-lens-trace-id";
|
|
12
|
+
};
|
|
13
|
+
export type Platform = 'ios' | 'android' | 'web' | 'unknown';
|
|
14
|
+
/** One request, as seen by the API. */
|
|
15
|
+
export interface NestRnLensEvent {
|
|
16
|
+
id: string;
|
|
17
|
+
traceId: string;
|
|
18
|
+
/** When the response was ready (ms since epoch). */
|
|
19
|
+
timestamp: number;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
source: {
|
|
22
|
+
/** From the x-nest-rn-lens-app header, or "unknown". */
|
|
23
|
+
app: string;
|
|
24
|
+
/** From the x-nest-rn-lens-caller header. */
|
|
25
|
+
caller?: string;
|
|
26
|
+
/** Guessed from the User-Agent. */
|
|
27
|
+
platform: Platform;
|
|
28
|
+
};
|
|
29
|
+
target: {
|
|
30
|
+
/** The `app` option of this API. */
|
|
31
|
+
app: string;
|
|
32
|
+
method: string;
|
|
33
|
+
/** Actual URL path without the query string, e.g. "/orders/42". */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Route pattern, e.g. "/orders/:id". Groups requests to the same endpoint. */
|
|
36
|
+
route: string;
|
|
37
|
+
controller: string;
|
|
38
|
+
handler: string;
|
|
39
|
+
};
|
|
40
|
+
status: number;
|
|
41
|
+
/** Message of the exception, when the handler threw. */
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface NestRnLensOptions {
|
|
45
|
+
/** Name of this API in the traffic graph, e.g. "api". */
|
|
46
|
+
app: string;
|
|
47
|
+
/**
|
|
48
|
+
* Turns reporting on or off. Defaults to `true` unless NODE_ENV is
|
|
49
|
+
* "production", so nothing runs in production unless you opt in.
|
|
50
|
+
*/
|
|
51
|
+
enabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Called with every event. Use it to forward traffic somewhere else.
|
|
54
|
+
* Errors thrown here are ignored, so they never affect the request.
|
|
55
|
+
*/
|
|
56
|
+
onEvent?: (event: NestRnLensEvent) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Log every event through Nest's Logger (a readable line, plus the JSON at
|
|
59
|
+
* debug level that the NestRN Lens VS Code extension reads). Defaults to `true`.
|
|
60
|
+
*/
|
|
61
|
+
log?: boolean;
|
|
62
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NEST_RN_LENS_HEADERS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Headers a client sends so the interceptor knows who made the request.
|
|
6
|
+
* The React Native client (@nest-rn-lens/react-native) fills them in dev.
|
|
7
|
+
*/
|
|
8
|
+
exports.NEST_RN_LENS_HEADERS = {
|
|
9
|
+
/** Name of the calling app, e.g. "mobile". */
|
|
10
|
+
app: 'x-nest-rn-lens-app',
|
|
11
|
+
/** Where in the client the call was made, e.g. "/…/src/screens/OrderList.tsx:42". */
|
|
12
|
+
caller: 'x-nest-rn-lens-caller',
|
|
13
|
+
/** Same id across every hop of one request chain. Echoed back on the response. */
|
|
14
|
+
traceId: 'x-nest-rn-lens-trace-id',
|
|
15
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Platform } from './types.js';
|
|
2
|
+
export interface HttpRequest {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
originalUrl?: string;
|
|
6
|
+
headers: Record<string, string | string[] | undefined>;
|
|
7
|
+
/** Express: the matched route. */
|
|
8
|
+
route?: {
|
|
9
|
+
path?: unknown;
|
|
10
|
+
};
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Fastify 4+: the matched route. */
|
|
13
|
+
routeOptions?: {
|
|
14
|
+
url?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Fastify 3. */
|
|
17
|
+
routerPath?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface HttpResponse {
|
|
20
|
+
statusCode: number;
|
|
21
|
+
/** Express (Node's ServerResponse). */
|
|
22
|
+
setHeader?(name: string, value: string): unknown;
|
|
23
|
+
/** Fastify reply. */
|
|
24
|
+
header?(name: string, value: string): unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function readHeader(req: HttpRequest, name: string): string | undefined;
|
|
27
|
+
export declare function writeHeader(res: HttpResponse, name: string, value: string): void;
|
|
28
|
+
export declare function requestPath(req: HttpRequest): string;
|
|
29
|
+
/** "/orders/:id" rather than "/orders/42", so one endpoint is one graph edge. */
|
|
30
|
+
export declare function routePattern(req: HttpRequest): string;
|
|
31
|
+
/**
|
|
32
|
+
* Best guess when the client doesn't say. React Native's fetch uses okhttp on
|
|
33
|
+
* Android and CFNetwork on iOS; browsers send a Mozilla user agent.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectPlatform(userAgent?: string): Platform;
|
package/dist/esm/http.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function readHeader(req, name) {
|
|
2
|
+
const value = req.headers[name];
|
|
3
|
+
return Array.isArray(value) ? value[0] : value;
|
|
4
|
+
}
|
|
5
|
+
export function writeHeader(res, name, value) {
|
|
6
|
+
if (typeof res.header === 'function') {
|
|
7
|
+
res.header(name, value);
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
res.setHeader?.(name, value);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function requestPath(req) {
|
|
14
|
+
return (req.originalUrl ?? req.url).split('?')[0];
|
|
15
|
+
}
|
|
16
|
+
/** "/orders/:id" rather than "/orders/42", so one endpoint is one graph edge. */
|
|
17
|
+
export function routePattern(req) {
|
|
18
|
+
const expressRoute = req.route?.path;
|
|
19
|
+
if (typeof expressRoute === 'string') {
|
|
20
|
+
return `${req.baseUrl ?? ''}${expressRoute}`;
|
|
21
|
+
}
|
|
22
|
+
return req.routeOptions?.url ?? req.routerPath ?? requestPath(req);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Best guess when the client doesn't say. React Native's fetch uses okhttp on
|
|
26
|
+
* Android and CFNetwork on iOS; browsers send a Mozilla user agent.
|
|
27
|
+
*/
|
|
28
|
+
export function detectPlatform(userAgent = '') {
|
|
29
|
+
if (/okhttp|android/i.test(userAgent)) {
|
|
30
|
+
return 'android';
|
|
31
|
+
}
|
|
32
|
+
if (/CFNetwork|Darwin|iPhone|iPad/i.test(userAgent)) {
|
|
33
|
+
return 'ios';
|
|
34
|
+
}
|
|
35
|
+
if (/Mozilla/i.test(userAgent)) {
|
|
36
|
+
return 'web';
|
|
37
|
+
}
|
|
38
|
+
return 'unknown';
|
|
39
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { type Observable } from 'rxjs';
|
|
3
|
+
import { NestRnLensReporter } from './reporter.js';
|
|
4
|
+
import { type NestRnLensOptions } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Records every HTTP request handled by a controller: who called it (from the
|
|
7
|
+
* x-nest-rn-lens-* headers), which handler answered, the status and the time.
|
|
8
|
+
*/
|
|
9
|
+
export declare class NestRnLensInterceptor implements NestInterceptor {
|
|
10
|
+
private readonly options;
|
|
11
|
+
private readonly reporter;
|
|
12
|
+
constructor(options: NestRnLensOptions, reporter: NestRnLensReporter);
|
|
13
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* The status Nest is about to send. Depending on the platform it may not be
|
|
16
|
+
* written to the response yet, so follow Nest's own rules: @HttpCode(),
|
|
17
|
+
* otherwise 201 for POST and 200 for everything else.
|
|
18
|
+
*/
|
|
19
|
+
private successStatus;
|
|
20
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { HttpException, Inject, Injectable, } from '@nestjs/common';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { tap } from 'rxjs';
|
|
16
|
+
import { NEST_RN_LENS_OPTIONS } from './constants.js';
|
|
17
|
+
import { detectPlatform, readHeader, requestPath, routePattern, writeHeader, } from './http.js';
|
|
18
|
+
import { NestRnLensReporter } from './reporter.js';
|
|
19
|
+
import { NEST_RN_LENS_HEADERS } from './types.js';
|
|
20
|
+
// Metadata key behind @HttpCode() (HTTP_CODE_METADATA in @nestjs/common/constants,
|
|
21
|
+
// unchanged since Nest 5). Inlined because that subpath resolves differently
|
|
22
|
+
// across Nest versions.
|
|
23
|
+
const HTTP_CODE_METADATA = '__httpCode__';
|
|
24
|
+
/**
|
|
25
|
+
* Records every HTTP request handled by a controller: who called it (from the
|
|
26
|
+
* x-nest-rn-lens-* headers), which handler answered, the status and the time.
|
|
27
|
+
*/
|
|
28
|
+
let NestRnLensInterceptor = class NestRnLensInterceptor {
|
|
29
|
+
options;
|
|
30
|
+
reporter;
|
|
31
|
+
constructor(options, reporter) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.reporter = reporter;
|
|
34
|
+
}
|
|
35
|
+
intercept(context, next) {
|
|
36
|
+
if (!this.options.enabled || context.getType() !== 'http') {
|
|
37
|
+
return next.handle();
|
|
38
|
+
}
|
|
39
|
+
const http = context.switchToHttp();
|
|
40
|
+
const req = http.getRequest();
|
|
41
|
+
const res = http.getResponse();
|
|
42
|
+
const start = performance.now();
|
|
43
|
+
const traceId = readHeader(req, NEST_RN_LENS_HEADERS.traceId) ?? randomUUID();
|
|
44
|
+
// Lets the client, and the next service in the chain, reuse the trace id.
|
|
45
|
+
writeHeader(res, NEST_RN_LENS_HEADERS.traceId, traceId);
|
|
46
|
+
const finish = (status, error) => {
|
|
47
|
+
const event = {
|
|
48
|
+
id: randomUUID(),
|
|
49
|
+
traceId,
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
durationMs: Math.round(performance.now() - start),
|
|
52
|
+
source: {
|
|
53
|
+
app: readHeader(req, NEST_RN_LENS_HEADERS.app) ?? 'unknown',
|
|
54
|
+
caller: readHeader(req, NEST_RN_LENS_HEADERS.caller),
|
|
55
|
+
platform: detectPlatform(readHeader(req, 'user-agent')),
|
|
56
|
+
},
|
|
57
|
+
target: {
|
|
58
|
+
app: this.options.app,
|
|
59
|
+
method: req.method,
|
|
60
|
+
path: requestPath(req),
|
|
61
|
+
route: routePattern(req),
|
|
62
|
+
controller: context.getClass().name,
|
|
63
|
+
handler: context.getHandler().name,
|
|
64
|
+
},
|
|
65
|
+
status,
|
|
66
|
+
error,
|
|
67
|
+
};
|
|
68
|
+
this.reporter.report(event);
|
|
69
|
+
};
|
|
70
|
+
return next.handle().pipe(tap({
|
|
71
|
+
next: () => finish(this.successStatus(context, req, res)),
|
|
72
|
+
error: (err) => finish(err instanceof HttpException ? err.getStatus() : 500, err instanceof Error ? err.message : String(err)),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The status Nest is about to send. Depending on the platform it may not be
|
|
77
|
+
* written to the response yet, so follow Nest's own rules: @HttpCode(),
|
|
78
|
+
* otherwise 201 for POST and 200 for everything else.
|
|
79
|
+
*/
|
|
80
|
+
successStatus(context, req, res) {
|
|
81
|
+
const declared = Reflect.getMetadata(HTTP_CODE_METADATA, context.getHandler());
|
|
82
|
+
if (declared) {
|
|
83
|
+
return declared;
|
|
84
|
+
}
|
|
85
|
+
// A handler using @Res() may have set its own status.
|
|
86
|
+
if (res.statusCode && res.statusCode !== 200) {
|
|
87
|
+
return res.statusCode;
|
|
88
|
+
}
|
|
89
|
+
return req.method === 'POST' ? 201 : 200;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
NestRnLensInterceptor = __decorate([
|
|
93
|
+
Injectable(),
|
|
94
|
+
__param(0, Inject(NEST_RN_LENS_OPTIONS)),
|
|
95
|
+
__metadata("design:paramtypes", [Object, NestRnLensReporter])
|
|
96
|
+
], NestRnLensInterceptor);
|
|
97
|
+
export { NestRnLensInterceptor };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
2
|
+
import type { NestRnLensOptions } from './types.js';
|
|
3
|
+
export declare class NestRnLensModule {
|
|
4
|
+
/**
|
|
5
|
+
* Registers the interceptor for every controller in the app.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* @Module({ imports: [NestRnLensModule.forRoot({ app: 'api' })] })
|
|
9
|
+
* export class AppModule {}
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
static forRoot(options: NestRnLensOptions): DynamicModule;
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var NestRnLensModule_1;
|
|
8
|
+
import { Module } from '@nestjs/common';
|
|
9
|
+
import { APP_INTERCEPTOR } from '@nestjs/core';
|
|
10
|
+
import { NEST_RN_LENS_OPTIONS } from './constants.js';
|
|
11
|
+
import { NestRnLensInterceptor } from './interceptor.js';
|
|
12
|
+
import { NestRnLensReporter } from './reporter.js';
|
|
13
|
+
let NestRnLensModule = NestRnLensModule_1 = class NestRnLensModule {
|
|
14
|
+
/**
|
|
15
|
+
* Registers the interceptor for every controller in the app.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* @Module({ imports: [NestRnLensModule.forRoot({ app: 'api' })] })
|
|
19
|
+
* export class AppModule {}
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
static forRoot(options) {
|
|
23
|
+
const resolved = {
|
|
24
|
+
...options,
|
|
25
|
+
enabled: options.enabled ?? process.env.NODE_ENV !== 'production',
|
|
26
|
+
log: options.log ?? true,
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
module: NestRnLensModule_1,
|
|
30
|
+
providers: [
|
|
31
|
+
{ provide: NEST_RN_LENS_OPTIONS, useValue: resolved },
|
|
32
|
+
NestRnLensReporter,
|
|
33
|
+
{ provide: APP_INTERCEPTOR, useClass: NestRnLensInterceptor },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
NestRnLensModule = NestRnLensModule_1 = __decorate([
|
|
39
|
+
Module({})
|
|
40
|
+
], NestRnLensModule);
|
|
41
|
+
export { NestRnLensModule };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { NestRnLensEvent, NestRnLensOptions } from './types.js';
|
|
2
|
+
/** Sends each event to the logger and to the `onEvent` callback. Never throws. */
|
|
3
|
+
export declare class NestRnLensReporter {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private readonly logger;
|
|
6
|
+
private repoRoot?;
|
|
7
|
+
constructor(options: NestRnLensOptions);
|
|
8
|
+
report(event: NestRnLensEvent): void;
|
|
9
|
+
/** "mobile (ios) apps/mobile/src/screens/orders.tsx:17 → GET /orders → OrdersController.findAll 200 4ms" */
|
|
10
|
+
private describe;
|
|
11
|
+
private shortPath;
|
|
12
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { dirname, join, relative } from 'node:path';
|
|
16
|
+
import { LOGGER_CONTEXT, NEST_RN_LENS_OPTIONS } from './constants.js';
|
|
17
|
+
/** Sends each event to the logger and to the `onEvent` callback. Never throws. */
|
|
18
|
+
let NestRnLensReporter = class NestRnLensReporter {
|
|
19
|
+
options;
|
|
20
|
+
logger = new Logger(LOGGER_CONTEXT);
|
|
21
|
+
repoRoot;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
report(event) {
|
|
26
|
+
try {
|
|
27
|
+
this.options.onEvent?.(event);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A broken callback must not break the request it observes.
|
|
31
|
+
}
|
|
32
|
+
if (this.options.log === false) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
this.logger.log(this.describe(event));
|
|
37
|
+
this.logger.debug(JSON.stringify(event));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Same for a broken logger.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** "mobile (ios) apps/mobile/src/screens/orders.tsx:17 → GET /orders → OrdersController.findAll 200 4ms" */
|
|
44
|
+
describe({ source, target, status, durationMs }) {
|
|
45
|
+
const from = [`${source.app} (${source.platform})`, this.shortPath(source.caller)].filter(Boolean).join(' ');
|
|
46
|
+
return `${from} → ${target.method} ${target.route} → ${target.controller}.${target.handler} ${status} ${durationMs}ms`;
|
|
47
|
+
}
|
|
48
|
+
// Clients send absolute paths (editors need them to open the file); logs are
|
|
49
|
+
// easier to read relative to the monorepo root.
|
|
50
|
+
shortPath(file) {
|
|
51
|
+
if (!file?.startsWith('/')) {
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
this.repoRoot ??= findRepoRoot(process.cwd());
|
|
55
|
+
return relative(this.repoRoot, file);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
NestRnLensReporter = __decorate([
|
|
59
|
+
Injectable(),
|
|
60
|
+
__param(0, Inject(NEST_RN_LENS_OPTIONS)),
|
|
61
|
+
__metadata("design:paramtypes", [Object])
|
|
62
|
+
], NestRnLensReporter);
|
|
63
|
+
export { NestRnLensReporter };
|
|
64
|
+
const ROOT_MARKERS = ['turbo.json', 'pnpm-workspace.yaml', '.git'];
|
|
65
|
+
function findRepoRoot(from) {
|
|
66
|
+
let dir = from;
|
|
67
|
+
while (!ROOT_MARKERS.some((marker) => existsSync(join(dir, marker)))) {
|
|
68
|
+
const parent = dirname(dir);
|
|
69
|
+
if (parent === dir) {
|
|
70
|
+
return from;
|
|
71
|
+
}
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
return dir;
|
|
75
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers a client sends so the interceptor knows who made the request.
|
|
3
|
+
* The React Native client (@nest-rn-lens/react-native) fills them in dev.
|
|
4
|
+
*/
|
|
5
|
+
export declare const NEST_RN_LENS_HEADERS: {
|
|
6
|
+
/** Name of the calling app, e.g. "mobile". */
|
|
7
|
+
readonly app: "x-nest-rn-lens-app";
|
|
8
|
+
/** Where in the client the call was made, e.g. "/…/src/screens/OrderList.tsx:42". */
|
|
9
|
+
readonly caller: "x-nest-rn-lens-caller";
|
|
10
|
+
/** Same id across every hop of one request chain. Echoed back on the response. */
|
|
11
|
+
readonly traceId: "x-nest-rn-lens-trace-id";
|
|
12
|
+
};
|
|
13
|
+
export type Platform = 'ios' | 'android' | 'web' | 'unknown';
|
|
14
|
+
/** One request, as seen by the API. */
|
|
15
|
+
export interface NestRnLensEvent {
|
|
16
|
+
id: string;
|
|
17
|
+
traceId: string;
|
|
18
|
+
/** When the response was ready (ms since epoch). */
|
|
19
|
+
timestamp: number;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
source: {
|
|
22
|
+
/** From the x-nest-rn-lens-app header, or "unknown". */
|
|
23
|
+
app: string;
|
|
24
|
+
/** From the x-nest-rn-lens-caller header. */
|
|
25
|
+
caller?: string;
|
|
26
|
+
/** Guessed from the User-Agent. */
|
|
27
|
+
platform: Platform;
|
|
28
|
+
};
|
|
29
|
+
target: {
|
|
30
|
+
/** The `app` option of this API. */
|
|
31
|
+
app: string;
|
|
32
|
+
method: string;
|
|
33
|
+
/** Actual URL path without the query string, e.g. "/orders/42". */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Route pattern, e.g. "/orders/:id". Groups requests to the same endpoint. */
|
|
36
|
+
route: string;
|
|
37
|
+
controller: string;
|
|
38
|
+
handler: string;
|
|
39
|
+
};
|
|
40
|
+
status: number;
|
|
41
|
+
/** Message of the exception, when the handler threw. */
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface NestRnLensOptions {
|
|
45
|
+
/** Name of this API in the traffic graph, e.g. "api". */
|
|
46
|
+
app: string;
|
|
47
|
+
/**
|
|
48
|
+
* Turns reporting on or off. Defaults to `true` unless NODE_ENV is
|
|
49
|
+
* "production", so nothing runs in production unless you opt in.
|
|
50
|
+
*/
|
|
51
|
+
enabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Called with every event. Use it to forward traffic somewhere else.
|
|
54
|
+
* Errors thrown here are ignored, so they never affect the request.
|
|
55
|
+
*/
|
|
56
|
+
onEvent?: (event: NestRnLensEvent) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Log every event through Nest's Logger (a readable line, plus the JSON at
|
|
59
|
+
* debug level that the NestRN Lens VS Code extension reads). Defaults to `true`.
|
|
60
|
+
*/
|
|
61
|
+
log?: boolean;
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers a client sends so the interceptor knows who made the request.
|
|
3
|
+
* The React Native client (@nest-rn-lens/react-native) fills them in dev.
|
|
4
|
+
*/
|
|
5
|
+
export const NEST_RN_LENS_HEADERS = {
|
|
6
|
+
/** Name of the calling app, e.g. "mobile". */
|
|
7
|
+
app: 'x-nest-rn-lens-app',
|
|
8
|
+
/** Where in the client the call was made, e.g. "/…/src/screens/OrderList.tsx:42". */
|
|
9
|
+
caller: 'x-nest-rn-lens-caller',
|
|
10
|
+
/** Same id across every hop of one request chain. Echoed back on the response. */
|
|
11
|
+
traceId: 'x-nest-rn-lens-trace-id',
|
|
12
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nest-rn-lens/nest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "NestJS interceptor that reports every request together with the React Native screen that made it. Part of NestRN Lens.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nestjs",
|
|
7
|
+
"nest",
|
|
8
|
+
"interceptor",
|
|
9
|
+
"react-native",
|
|
10
|
+
"expo",
|
|
11
|
+
"turborepo",
|
|
12
|
+
"monorepo",
|
|
13
|
+
"observability",
|
|
14
|
+
"tracing",
|
|
15
|
+
"devtools"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "isa",
|
|
19
|
+
"main": "./dist/cjs/index.js",
|
|
20
|
+
"types": "./dist/cjs/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/esm/index.d.ts",
|
|
25
|
+
"default": "./dist/esm/index.js"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/cjs/index.d.ts",
|
|
29
|
+
"default": "./dist/cjs/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"clean": "node -e \"for (const d of ['dist', 'dist-test']) require('fs').rmSync(d, { recursive: true, force: true })\"",
|
|
45
|
+
"build": "npm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.esm.json && node scripts/mark-formats.cjs",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "npm run clean && tsc -p tsconfig.test.json && node --test \"dist-test/test/**/*.test.js\"",
|
|
48
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
|
52
|
+
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
|
53
|
+
"rxjs": "^7.0.0"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@nestjs/common": "^12.1.2",
|
|
60
|
+
"@nestjs/core": "^12.1.2",
|
|
61
|
+
"@nestjs/platform-express": "^12.1.2",
|
|
62
|
+
"@nestjs/platform-fastify": "^12.1.2",
|
|
63
|
+
"@types/node": "^22.20.5",
|
|
64
|
+
"reflect-metadata": "^0.2.2",
|
|
65
|
+
"rxjs": "^7.8.2",
|
|
66
|
+
"typescript": "^6.0.3"
|
|
67
|
+
},
|
|
68
|
+
"module": "./dist/esm/index.js"
|
|
69
|
+
}
|