@gerbaudo/sdk-node 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/dist/client.d.ts +20 -0
- package/dist/client.js +63 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +87 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.js +82 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EndpointRegistration, InterceptPayload } from './types.js';
|
|
2
|
+
export declare class GerbaudoClient {
|
|
3
|
+
private daemonUrl;
|
|
4
|
+
private batchQueue;
|
|
5
|
+
private batchInterval;
|
|
6
|
+
private batchSize;
|
|
7
|
+
private timer;
|
|
8
|
+
private registered;
|
|
9
|
+
private flushing;
|
|
10
|
+
constructor(opts?: {
|
|
11
|
+
daemonUrl?: string;
|
|
12
|
+
batchInterval?: number;
|
|
13
|
+
batchSize?: number;
|
|
14
|
+
});
|
|
15
|
+
start(): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
registerEndpoint(endpoint: EndpointRegistration): Promise<void>;
|
|
18
|
+
recordIntercept(payload: InterceptPayload): void;
|
|
19
|
+
private flush;
|
|
20
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULT_DAEMON_URL = 'http://127.0.0.1:9876';
|
|
2
|
+
export class GerbaudoClient {
|
|
3
|
+
daemonUrl;
|
|
4
|
+
batchQueue = [];
|
|
5
|
+
batchInterval;
|
|
6
|
+
batchSize;
|
|
7
|
+
timer = null;
|
|
8
|
+
registered = new Set();
|
|
9
|
+
flushing = false;
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
this.daemonUrl = opts?.daemonUrl ?? DEFAULT_DAEMON_URL;
|
|
12
|
+
this.batchInterval = opts?.batchInterval ?? 2000;
|
|
13
|
+
this.batchSize = opts?.batchSize ?? 50;
|
|
14
|
+
}
|
|
15
|
+
start() {
|
|
16
|
+
this.timer = setInterval(() => this.flush(), this.batchInterval);
|
|
17
|
+
}
|
|
18
|
+
stop() {
|
|
19
|
+
if (this.timer) {
|
|
20
|
+
clearInterval(this.timer);
|
|
21
|
+
this.timer = null;
|
|
22
|
+
}
|
|
23
|
+
this.flush();
|
|
24
|
+
}
|
|
25
|
+
async registerEndpoint(endpoint) {
|
|
26
|
+
const key = `${endpoint.method}:${endpoint.path}`;
|
|
27
|
+
if (this.registered.has(key))
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
await fetch(`${this.daemonUrl}/api/catalog/register`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify(endpoint),
|
|
34
|
+
});
|
|
35
|
+
this.registered.add(key);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// daemon might not be running yet
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
recordIntercept(payload) {
|
|
42
|
+
this.batchQueue.push(payload);
|
|
43
|
+
if (this.batchQueue.length >= this.batchSize) {
|
|
44
|
+
this.flush();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
flush() {
|
|
48
|
+
if (this.flushing || this.batchQueue.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
this.flushing = true;
|
|
51
|
+
const batch = this.batchQueue.splice(0, this.batchSize);
|
|
52
|
+
fetch(`${this.daemonUrl}/api/intercept/record`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify(batch.length === 1 ? batch[0] : batch),
|
|
56
|
+
}).catch(() => {
|
|
57
|
+
// re-queue on failure
|
|
58
|
+
this.batchQueue.unshift(...batch);
|
|
59
|
+
}).finally(() => {
|
|
60
|
+
this.flushing = false;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { GerbaudoClient } from './client.js';
|
|
3
|
+
describe('GerbaudoClient', () => {
|
|
4
|
+
let client;
|
|
5
|
+
let fetchMock;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
fetchMock = vi.fn();
|
|
8
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
|
9
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
client = new GerbaudoClient({ daemonUrl: 'http://127.0.0.1:9999', batchInterval: 5000, batchSize: 10 });
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
client.stop();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
describe('start / stop', () => {
|
|
19
|
+
it('starts a timer that flushes on interval', () => {
|
|
20
|
+
client.start();
|
|
21
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
22
|
+
vi.advanceTimersByTime(5000);
|
|
23
|
+
expect(fetchMock).not.toHaveBeenCalled(); // nothing to flush
|
|
24
|
+
});
|
|
25
|
+
it('flushes remaining items on stop', () => {
|
|
26
|
+
client.start();
|
|
27
|
+
client.recordIntercept({
|
|
28
|
+
endpointId: 'ep1', method: 'GET', path: '/api/test', status: 200, durationMs: 10,
|
|
29
|
+
});
|
|
30
|
+
client.stop();
|
|
31
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('registerEndpoint', () => {
|
|
35
|
+
it('POSTs to daemon and deduplicates', async () => {
|
|
36
|
+
await client.registerEndpoint({ method: 'GET', path: '/api/users' });
|
|
37
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
38
|
+
await client.registerEndpoint({ method: 'GET', path: '/api/users' });
|
|
39
|
+
expect(fetchMock).toHaveBeenCalledTimes(1); // dedup
|
|
40
|
+
});
|
|
41
|
+
it('does not throw when daemon is unreachable', async () => {
|
|
42
|
+
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
43
|
+
await expect(client.registerEndpoint({ method: 'GET', path: '/api/test' })).resolves.toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('recordIntercept', () => {
|
|
47
|
+
it('queues items and flushes when batch size is reached', () => {
|
|
48
|
+
client.start();
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
client.recordIntercept({
|
|
51
|
+
endpointId: `ep${i}`, method: 'GET', path: '/api/test', status: 200, durationMs: i,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
55
|
+
const callBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
56
|
+
expect(Array.isArray(callBody)).toBe(true);
|
|
57
|
+
expect(callBody).toHaveLength(10);
|
|
58
|
+
});
|
|
59
|
+
it('does not flush concurrently when a flush is in flight', () => {
|
|
60
|
+
fetchMock.mockImplementation(() => new Promise(() => { })); // never resolves
|
|
61
|
+
client.start();
|
|
62
|
+
for (let i = 0; i < 20; i++) {
|
|
63
|
+
client.recordIntercept({
|
|
64
|
+
endpointId: `ep${i}`, method: 'GET', path: '/api/test', status: 200, durationMs: i,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Only one flush should have been triggered (first batch of 10)
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
69
|
+
});
|
|
70
|
+
it('re-queues batch on failure', async () => {
|
|
71
|
+
fetchMock.mockRejectedValueOnce(new Error('fail'));
|
|
72
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
|
73
|
+
client.start();
|
|
74
|
+
for (let i = 0; i < 10; i++) {
|
|
75
|
+
client.recordIntercept({
|
|
76
|
+
endpointId: `ep${i}`, method: 'GET', path: '/api/test', status: 200, durationMs: i,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
80
|
+
// After the failed fetch, items should be re-queued
|
|
81
|
+
// Wait for the next timer tick
|
|
82
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
83
|
+
// The timer flush should try again
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GerbaudoOptions } from './types.js';
|
|
2
|
+
export declare function gerbaudo(opts?: GerbaudoOptions): {
|
|
3
|
+
(req: import("express").Request, res: import("express").Response, next: import("express").NextFunction): void;
|
|
4
|
+
discover: (app: {
|
|
5
|
+
_router?: {
|
|
6
|
+
stack: unknown[];
|
|
7
|
+
};
|
|
8
|
+
}) => void;
|
|
9
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { GerbaudoOptions } from './types.js';
|
|
3
|
+
export declare function createMiddleware(opts?: GerbaudoOptions): {
|
|
4
|
+
(req: Request, res: Response, next: NextFunction): void;
|
|
5
|
+
discover: (app: {
|
|
6
|
+
_router?: {
|
|
7
|
+
stack: unknown[];
|
|
8
|
+
};
|
|
9
|
+
}) => void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { GerbaudoClient } from './client.js';
|
|
2
|
+
function extractRoutePath(req) {
|
|
3
|
+
return req.route?.path ?? req.path;
|
|
4
|
+
}
|
|
5
|
+
function getEndpointId(client, method, path) {
|
|
6
|
+
return `${method}:${path}`;
|
|
7
|
+
}
|
|
8
|
+
export function createMiddleware(opts) {
|
|
9
|
+
const client = new GerbaudoClient({
|
|
10
|
+
daemonUrl: opts?.daemonUrl,
|
|
11
|
+
batchInterval: opts?.batchInterval,
|
|
12
|
+
batchSize: opts?.batchSize,
|
|
13
|
+
});
|
|
14
|
+
client.start();
|
|
15
|
+
if (opts?.app) {
|
|
16
|
+
discoverRoutes(opts.app);
|
|
17
|
+
}
|
|
18
|
+
function discoverRoutes(app) {
|
|
19
|
+
if (!app._router?.stack)
|
|
20
|
+
return;
|
|
21
|
+
for (const layer of app._router.stack) {
|
|
22
|
+
if (layer.route) {
|
|
23
|
+
const route = layer.route;
|
|
24
|
+
const methods = Object.keys(route.methods);
|
|
25
|
+
for (const method of methods) {
|
|
26
|
+
client.registerEndpoint({
|
|
27
|
+
method: method.toUpperCase(),
|
|
28
|
+
path: route.path,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function safeStringify(val) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(val);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return String(val);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function middleware(req, res, next) {
|
|
43
|
+
const start = performance.now();
|
|
44
|
+
let responseBody = null;
|
|
45
|
+
const _json = res.json.bind(res);
|
|
46
|
+
const _send = res.send.bind(res);
|
|
47
|
+
const _end = res.end.bind(res);
|
|
48
|
+
res.json = function (body, ...args) {
|
|
49
|
+
responseBody = body;
|
|
50
|
+
return _json(body, ...args);
|
|
51
|
+
};
|
|
52
|
+
res.send = function (body, ...args) {
|
|
53
|
+
responseBody = body ?? responseBody;
|
|
54
|
+
return _send(body, ...args);
|
|
55
|
+
};
|
|
56
|
+
res.end = function (chunk, ...args) {
|
|
57
|
+
try {
|
|
58
|
+
const durationMs = Math.round(performance.now() - start);
|
|
59
|
+
const path = extractRoutePath(req);
|
|
60
|
+
const endpointId = getEndpointId(client, req.method, path);
|
|
61
|
+
client.recordIntercept({
|
|
62
|
+
endpointId,
|
|
63
|
+
method: req.method.toUpperCase(),
|
|
64
|
+
path,
|
|
65
|
+
status: res.statusCode,
|
|
66
|
+
requestHeaders: safeStringify(req.headers),
|
|
67
|
+
requestBody: safeStringify(req.body),
|
|
68
|
+
responseHeaders: safeStringify(res.getHeaders()),
|
|
69
|
+
responseBody: typeof responseBody === 'string' ? responseBody : safeStringify(responseBody),
|
|
70
|
+
durationMs,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// never crash the backend because of instrumentation
|
|
75
|
+
}
|
|
76
|
+
return _end(chunk, ...args);
|
|
77
|
+
};
|
|
78
|
+
next();
|
|
79
|
+
}
|
|
80
|
+
middleware.discover = discoverRoutes;
|
|
81
|
+
return middleware;
|
|
82
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface EndpointRegistration {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
params?: string;
|
|
5
|
+
bodySchema?: string;
|
|
6
|
+
responseSchema?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface InterceptPayload {
|
|
9
|
+
endpointId: string;
|
|
10
|
+
method: string;
|
|
11
|
+
path: string;
|
|
12
|
+
status: number;
|
|
13
|
+
requestHeaders?: string;
|
|
14
|
+
requestBody?: string;
|
|
15
|
+
responseHeaders?: string;
|
|
16
|
+
responseBody?: string;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
}
|
|
19
|
+
export interface GerbaudoOptions {
|
|
20
|
+
daemonUrl?: string;
|
|
21
|
+
batchInterval?: number;
|
|
22
|
+
batchSize?: number;
|
|
23
|
+
app?: {
|
|
24
|
+
_router?: {
|
|
25
|
+
stack: unknown[];
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gerbaudo/sdk-node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Express middleware for Gerbaudo API instrumentation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"package.json",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/express": "^4.17.0",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"typescript": "^5.5.0",
|
|
27
|
+
"vitest": "^4.1.9"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|