@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.
@@ -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
+ });
@@ -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,4 @@
1
+ import { createMiddleware } from './middleware.js';
2
+ export function gerbaudo(opts) {
3
+ return createMiddleware(opts);
4
+ }
@@ -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
+ }
@@ -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
+ }