recker 1.0.22 → 1.0.23
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/cli/index.js +54 -0
- package/dist/cli/tui/shell-search.js +4 -1
- package/dist/mcp/search/embedder.d.ts +9 -0
- package/dist/mcp/search/embedder.js +83 -0
- package/dist/mcp/search/hybrid-search.d.ts +1 -0
- package/dist/mcp/search/hybrid-search.js +56 -4
- package/dist/mcp/search/index.d.ts +1 -0
- package/dist/mcp/search/index.js +1 -0
- package/dist/mcp/search/types.d.ts +1 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +57 -1
- package/dist/mini.d.ts +6 -0
- package/dist/mini.js +34 -29
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +1 -1
- package/dist/testing/mock-http-server.d.ts +25 -0
- package/dist/testing/mock-http-server.js +45 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -993,6 +993,60 @@ ${colors.bold(colors.yellow('Examples:'))}
|
|
|
993
993
|
process.exit(0);
|
|
994
994
|
});
|
|
995
995
|
});
|
|
996
|
+
serve
|
|
997
|
+
.command('webhook')
|
|
998
|
+
.alias('wh')
|
|
999
|
+
.description('Start a webhook receiver server')
|
|
1000
|
+
.option('-p, --port <number>', 'Port to listen on', '3000')
|
|
1001
|
+
.option('-h, --host <string>', 'Host to bind to', '127.0.0.1')
|
|
1002
|
+
.option('-s, --status <code>', 'Response status code (200 or 204)', '204')
|
|
1003
|
+
.option('-q, --quiet', 'Disable logging', false)
|
|
1004
|
+
.addHelpText('after', `
|
|
1005
|
+
${colors.bold(colors.yellow('Examples:'))}
|
|
1006
|
+
${colors.green('$ rek serve webhook')} ${colors.gray('Start on port 3000')}
|
|
1007
|
+
${colors.green('$ rek serve wh -p 8080')} ${colors.gray('Start on port 8080')}
|
|
1008
|
+
${colors.green('$ rek serve webhook --status 200')} ${colors.gray('Return 200 instead of 204')}
|
|
1009
|
+
|
|
1010
|
+
${colors.bold(colors.yellow('Endpoints:'))}
|
|
1011
|
+
* / ${colors.gray('Receive webhook without ID')}
|
|
1012
|
+
* /:id ${colors.gray('Receive webhook with custom ID')}
|
|
1013
|
+
|
|
1014
|
+
${colors.bold(colors.yellow('Methods:'))}
|
|
1015
|
+
${colors.gray('GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS')}
|
|
1016
|
+
`)
|
|
1017
|
+
.action(async (options) => {
|
|
1018
|
+
const { createWebhookServer } = await import('../testing/mock-http-server.js');
|
|
1019
|
+
const status = parseInt(options.status);
|
|
1020
|
+
if (status !== 200 && status !== 204) {
|
|
1021
|
+
console.error(colors.red('Status must be 200 or 204'));
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
const server = await createWebhookServer({
|
|
1025
|
+
port: parseInt(options.port),
|
|
1026
|
+
host: options.host,
|
|
1027
|
+
status,
|
|
1028
|
+
log: !options.quiet,
|
|
1029
|
+
});
|
|
1030
|
+
console.log(colors.green(`
|
|
1031
|
+
┌─────────────────────────────────────────────┐
|
|
1032
|
+
│ ${colors.bold('Recker Webhook Receiver')} │
|
|
1033
|
+
├─────────────────────────────────────────────┤
|
|
1034
|
+
│ URL: ${colors.cyan(server.url.padEnd(37))}│
|
|
1035
|
+
│ Status: ${colors.yellow(String(status).padEnd(34))}│
|
|
1036
|
+
├─────────────────────────────────────────────┤
|
|
1037
|
+
│ ${colors.cyan('*')} ${colors.cyan('/')} ${colors.gray('Webhook without ID')} │
|
|
1038
|
+
│ ${colors.cyan('*')} ${colors.cyan('/:id')} ${colors.gray('Webhook with custom ID')} │
|
|
1039
|
+
├─────────────────────────────────────────────┤
|
|
1040
|
+
│ Press ${colors.bold('Ctrl+C')} to stop │
|
|
1041
|
+
└─────────────────────────────────────────────┘
|
|
1042
|
+
`));
|
|
1043
|
+
process.on('SIGINT', async () => {
|
|
1044
|
+
console.log(colors.yellow('\nShutting down...'));
|
|
1045
|
+
console.log(colors.gray(`Total webhooks received: ${server.webhooks.length}`));
|
|
1046
|
+
await server.stop();
|
|
1047
|
+
process.exit(0);
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
996
1050
|
serve
|
|
997
1051
|
.command('websocket')
|
|
998
1052
|
.alias('ws')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHybridSearch } from '../../mcp/search/index.js';
|
|
1
|
+
import { createHybridSearch, createEmbedder, isFastembedAvailable } from '../../mcp/search/index.js';
|
|
2
2
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
3
3
|
import { join, relative, extname, basename, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
@@ -24,6 +24,9 @@ export class ShellSearch {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
this.hybridSearch = createHybridSearch({ debug: false });
|
|
27
|
+
if (await isFastembedAvailable()) {
|
|
28
|
+
this.hybridSearch.setEmbedder(createEmbedder());
|
|
29
|
+
}
|
|
27
30
|
this.buildIndex();
|
|
28
31
|
await this.hybridSearch.initialize(this.docsIndex);
|
|
29
32
|
this.initialized = true;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function unloadEmbedder(): void;
|
|
2
|
+
export declare function embed(text: string): Promise<number[]>;
|
|
3
|
+
export declare function embedBatch(texts: string[]): Promise<number[][]>;
|
|
4
|
+
export declare function createEmbedder(): (text: string, model?: string) => Promise<number[]>;
|
|
5
|
+
export declare function isFastembedAvailable(): Promise<boolean>;
|
|
6
|
+
export declare function getModelInfo(): {
|
|
7
|
+
name: string;
|
|
8
|
+
dimensions: number;
|
|
9
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const MODEL_NAME = 'BGESmallENV15';
|
|
2
|
+
const MODEL_DIMENSIONS = 384;
|
|
3
|
+
let embedderInstance = null;
|
|
4
|
+
let embedderPromise = null;
|
|
5
|
+
let idleTimer = null;
|
|
6
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
|
+
function resetIdleTimer() {
|
|
8
|
+
if (idleTimer) {
|
|
9
|
+
clearTimeout(idleTimer);
|
|
10
|
+
}
|
|
11
|
+
idleTimer = setTimeout(() => {
|
|
12
|
+
unloadEmbedder();
|
|
13
|
+
}, IDLE_TIMEOUT_MS);
|
|
14
|
+
}
|
|
15
|
+
export function unloadEmbedder() {
|
|
16
|
+
if (idleTimer) {
|
|
17
|
+
clearTimeout(idleTimer);
|
|
18
|
+
idleTimer = null;
|
|
19
|
+
}
|
|
20
|
+
embedderInstance = null;
|
|
21
|
+
embedderPromise = null;
|
|
22
|
+
}
|
|
23
|
+
async function getEmbedder() {
|
|
24
|
+
if (embedderInstance) {
|
|
25
|
+
resetIdleTimer();
|
|
26
|
+
return embedderInstance;
|
|
27
|
+
}
|
|
28
|
+
if (embedderPromise) {
|
|
29
|
+
return embedderPromise;
|
|
30
|
+
}
|
|
31
|
+
embedderPromise = (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const { EmbeddingModel, FlagEmbedding } = await import('fastembed');
|
|
34
|
+
embedderInstance = await FlagEmbedding.init({
|
|
35
|
+
model: EmbeddingModel[MODEL_NAME],
|
|
36
|
+
});
|
|
37
|
+
resetIdleTimer();
|
|
38
|
+
return embedderInstance;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
embedderPromise = null;
|
|
42
|
+
throw new Error(`Failed to initialize embedder: ${error}`);
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
return embedderPromise;
|
|
46
|
+
}
|
|
47
|
+
export async function embed(text) {
|
|
48
|
+
const embedder = await getEmbedder();
|
|
49
|
+
const embeddings = await embedder.embed([text]);
|
|
50
|
+
for await (const embedding of embeddings) {
|
|
51
|
+
const vec = Array.isArray(embedding) ? embedding[0] : embedding;
|
|
52
|
+
return Array.from(vec);
|
|
53
|
+
}
|
|
54
|
+
throw new Error('No embedding generated');
|
|
55
|
+
}
|
|
56
|
+
export async function embedBatch(texts) {
|
|
57
|
+
const embedder = await getEmbedder();
|
|
58
|
+
const embeddings = await embedder.embed(texts);
|
|
59
|
+
const results = [];
|
|
60
|
+
for await (const embedding of embeddings) {
|
|
61
|
+
const vec = Array.isArray(embedding) ? embedding[0] : embedding;
|
|
62
|
+
results.push(Array.from(vec));
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
export function createEmbedder() {
|
|
67
|
+
return (text, _model) => embed(text);
|
|
68
|
+
}
|
|
69
|
+
export async function isFastembedAvailable() {
|
|
70
|
+
try {
|
|
71
|
+
await import('fastembed');
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function getModelInfo() {
|
|
79
|
+
return {
|
|
80
|
+
name: MODEL_NAME,
|
|
81
|
+
dimensions: MODEL_DIMENSIONS,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -7,6 +7,7 @@ export declare class HybridSearch {
|
|
|
7
7
|
private initialized;
|
|
8
8
|
private config;
|
|
9
9
|
constructor(config?: HybridSearchConfig);
|
|
10
|
+
setEmbedder(embedder: (text: string, model?: string) => Promise<number[]>): void;
|
|
10
11
|
initialize(docs: IndexedDoc[]): Promise<void>;
|
|
11
12
|
private loadPrecomputedEmbeddings;
|
|
12
13
|
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
|
@@ -16,8 +16,12 @@ export class HybridSearch {
|
|
|
16
16
|
fuzzyWeight: config.fuzzyWeight ?? 0.5,
|
|
17
17
|
semanticWeight: config.semanticWeight ?? 0.5,
|
|
18
18
|
debug: config.debug ?? false,
|
|
19
|
+
embedder: config.embedder,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
setEmbedder(embedder) {
|
|
23
|
+
this.config.embedder = embedder;
|
|
24
|
+
}
|
|
21
25
|
async initialize(docs) {
|
|
22
26
|
this.docs = docs;
|
|
23
27
|
this.fuse = new Fuse(docs, {
|
|
@@ -55,8 +59,13 @@ export class HybridSearch {
|
|
|
55
59
|
}
|
|
56
60
|
if (this.embeddingsData) {
|
|
57
61
|
for (const entry of this.embeddingsData.documents) {
|
|
58
|
-
if (entry.vector
|
|
59
|
-
|
|
62
|
+
if (entry.vector) {
|
|
63
|
+
const vec = Array.isArray(entry.vector)
|
|
64
|
+
? entry.vector
|
|
65
|
+
: Object.values(entry.vector);
|
|
66
|
+
if (vec.length > 0) {
|
|
67
|
+
this.vectors.set(entry.id, vec);
|
|
68
|
+
}
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
this.log(`Loaded ${this.vectors.size} pre-computed embeddings (model: ${this.embeddingsData.model})`);
|
|
@@ -86,7 +95,7 @@ export class HybridSearch {
|
|
|
86
95
|
this.log(`Fuzzy search found ${fuzzyResults.length} results`);
|
|
87
96
|
}
|
|
88
97
|
if ((mode === 'hybrid' || mode === 'semantic') && this.vectors.size > 0) {
|
|
89
|
-
const semanticResults = this.semanticSearch(searchQuery, limit * 2, category);
|
|
98
|
+
const semanticResults = await this.semanticSearch(searchQuery, limit * 2, category);
|
|
90
99
|
for (const result of semanticResults) {
|
|
91
100
|
const existing = results.get(result.id);
|
|
92
101
|
if (existing) {
|
|
@@ -152,10 +161,53 @@ export class HybridSearch {
|
|
|
152
161
|
source: 'fuzzy',
|
|
153
162
|
}));
|
|
154
163
|
}
|
|
155
|
-
semanticSearch(query, limit, category) {
|
|
164
|
+
async semanticSearch(query, limit, category) {
|
|
156
165
|
if (!this.embeddingsData || this.vectors.size === 0) {
|
|
157
166
|
return [];
|
|
158
167
|
}
|
|
168
|
+
if (this.config.embedder) {
|
|
169
|
+
try {
|
|
170
|
+
const model = this.embeddingsData.model;
|
|
171
|
+
const queryVector = await this.config.embedder(query, model);
|
|
172
|
+
this.log(`Generated query vector using provided embedder (model: ${model})`);
|
|
173
|
+
const scores = [];
|
|
174
|
+
for (const [id, vector] of this.vectors) {
|
|
175
|
+
if (category) {
|
|
176
|
+
const entry = this.embeddingsData.documents.find((e) => e.id === id);
|
|
177
|
+
if (!entry || !entry.category.toLowerCase().includes(category.toLowerCase())) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (vector.length !== queryVector.length)
|
|
182
|
+
continue;
|
|
183
|
+
const score = cosineSimilarity(queryVector, vector);
|
|
184
|
+
if (score > 0.05) {
|
|
185
|
+
scores.push({ id, score });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const results = [];
|
|
189
|
+
for (const s of scores.sort((a, b) => b.score - a.score).slice(0, limit)) {
|
|
190
|
+
const doc = this.docs.find((d) => d.id === s.id);
|
|
191
|
+
const entry = this.embeddingsData.documents.find((e) => e.id === s.id);
|
|
192
|
+
if (!doc && !entry)
|
|
193
|
+
continue;
|
|
194
|
+
const content = doc?.content || '';
|
|
195
|
+
results.push({
|
|
196
|
+
id: s.id,
|
|
197
|
+
path: doc?.path || entry?.path || '',
|
|
198
|
+
title: doc?.title || entry?.title || 'Unknown',
|
|
199
|
+
content,
|
|
200
|
+
snippet: this.extractSnippet(content, query),
|
|
201
|
+
score: s.score,
|
|
202
|
+
source: 'semantic',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return results;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.log(`Embedder failed: ${error}. Falling back to synthetic vectors.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
159
211
|
const queryTerms = this.tokenize(query);
|
|
160
212
|
const scores = [];
|
|
161
213
|
for (const entry of this.embeddingsData.documents) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { HybridSearch, createHybridSearch } from './hybrid-search.js';
|
|
2
2
|
export { cosineSimilarity, levenshtein, stringSimilarity, reciprocalRankFusion, combineScores, } from './math.js';
|
|
3
|
+
export { embed, embedBatch, createEmbedder, isFastembedAvailable, getModelInfo, unloadEmbedder, } from './embedder.js';
|
|
3
4
|
export type { IndexedDoc, SearchResult, SearchOptions, HybridSearchConfig, EmbeddingsData, EmbeddingEntry, } from './types.js';
|
package/dist/mcp/search/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { HybridSearch, createHybridSearch } from './hybrid-search.js';
|
|
2
2
|
export { cosineSimilarity, levenshtein, stringSimilarity, reciprocalRankFusion, combineScores, } from './math.js';
|
|
3
|
+
export { embed, embedBatch, createEmbedder, isFastembedAvailable, getModelInfo, unloadEmbedder, } from './embedder.js';
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -22,9 +22,13 @@ export declare class MCPServer {
|
|
|
22
22
|
private sseClients;
|
|
23
23
|
private initialized;
|
|
24
24
|
private toolRegistry;
|
|
25
|
+
private aiClient?;
|
|
25
26
|
constructor(options?: MCPServerOptions);
|
|
26
27
|
private indexReady;
|
|
27
28
|
private ensureIndexReady;
|
|
29
|
+
private initAI;
|
|
30
|
+
private fastEmbedModel;
|
|
31
|
+
private generateEmbedding;
|
|
28
32
|
private log;
|
|
29
33
|
private findDocsPath;
|
|
30
34
|
private findExamplesPath;
|
package/dist/mcp/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
|
|
3
3
|
import { join, relative, extname, basename, dirname } from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
+
import { createAI } from '../ai/index.js';
|
|
6
7
|
import { createHybridSearch } from './search/index.js';
|
|
7
8
|
import { UnsupportedError } from '../core/errors.js';
|
|
8
9
|
import { getIpInfo, isValidIP, isGeoIPAvailable, isBogon, isIPv6 } from './ip-intel.js';
|
|
@@ -19,6 +20,7 @@ export class MCPServer {
|
|
|
19
20
|
sseClients = new Set();
|
|
20
21
|
initialized = false;
|
|
21
22
|
toolRegistry;
|
|
23
|
+
aiClient;
|
|
22
24
|
constructor(options = {}) {
|
|
23
25
|
this.options = {
|
|
24
26
|
name: options.name || 'recker-docs',
|
|
@@ -32,7 +34,11 @@ export class MCPServer {
|
|
|
32
34
|
toolsFilter: options.toolsFilter || [],
|
|
33
35
|
toolPaths: options.toolPaths || [],
|
|
34
36
|
};
|
|
35
|
-
this.
|
|
37
|
+
this.aiClient = this.initAI();
|
|
38
|
+
this.hybridSearch = createHybridSearch({
|
|
39
|
+
debug: this.options.debug,
|
|
40
|
+
embedder: (text, model) => this.generateEmbedding(text, model || 'BGESmallENV15'),
|
|
41
|
+
});
|
|
36
42
|
this.toolRegistry = new ToolRegistry();
|
|
37
43
|
this.registerInternalTools();
|
|
38
44
|
this.toolRegistry.registerModule({
|
|
@@ -47,6 +53,56 @@ export class MCPServer {
|
|
|
47
53
|
}
|
|
48
54
|
await this.indexReady;
|
|
49
55
|
}
|
|
56
|
+
initAI() {
|
|
57
|
+
const hasKeys = process.env.OPENAI_API_KEY || process.env.GOOGLE_API_KEY;
|
|
58
|
+
const hasLocal = process.env.OLLAMA_HOST || true;
|
|
59
|
+
if (!hasKeys) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return createAI({ debug: this.options.debug });
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
this.log('Failed to initialize AI client:', e);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
fastEmbedModel = null;
|
|
71
|
+
async generateEmbedding(text, model) {
|
|
72
|
+
if (model.toLowerCase().includes('bge')) {
|
|
73
|
+
if (!this.fastEmbedModel) {
|
|
74
|
+
try {
|
|
75
|
+
const fastembed = await import('fastembed');
|
|
76
|
+
const { FlagEmbedding } = fastembed;
|
|
77
|
+
this.fastEmbedModel = await FlagEmbedding.init({
|
|
78
|
+
model: model,
|
|
79
|
+
showDownloadProgress: false
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
throw new Error(`FastEmbed required for model ${model}. Install with: pnpm add fastembed`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const vectors = this.fastEmbedModel.embed([text]);
|
|
87
|
+
for await (const v of vectors)
|
|
88
|
+
return Array.from(v);
|
|
89
|
+
throw new Error('No vector generated');
|
|
90
|
+
}
|
|
91
|
+
if (!this.aiClient) {
|
|
92
|
+
throw new Error('No AI client available for external embeddings');
|
|
93
|
+
}
|
|
94
|
+
let provider = 'openai';
|
|
95
|
+
if (model.includes('google') || model.includes('gecko'))
|
|
96
|
+
provider = 'google';
|
|
97
|
+
else if (model.includes('nomic') || model.includes('llama'))
|
|
98
|
+
provider = 'ollama';
|
|
99
|
+
const res = await this.aiClient.embed({
|
|
100
|
+
input: text,
|
|
101
|
+
provider,
|
|
102
|
+
model
|
|
103
|
+
});
|
|
104
|
+
return res.embeddings[0];
|
|
105
|
+
}
|
|
50
106
|
log(message, data) {
|
|
51
107
|
if (this.options.debug) {
|
|
52
108
|
if (this.options.transport === 'stdio') {
|
package/dist/mini.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface MiniClient {
|
|
|
16
16
|
put<T = unknown>(path: string, body?: unknown): Promise<MiniResponse<T>>;
|
|
17
17
|
patch<T = unknown>(path: string, body?: unknown): Promise<MiniResponse<T>>;
|
|
18
18
|
delete<T = unknown>(path: string): Promise<MiniResponse<T>>;
|
|
19
|
+
head<T = unknown>(path: string): Promise<MiniResponse<T>>;
|
|
20
|
+
options<T = unknown>(path: string): Promise<MiniResponse<T>>;
|
|
21
|
+
trace<T = unknown>(path: string): Promise<MiniResponse<T>>;
|
|
22
|
+
purge<T = unknown>(path: string): Promise<MiniResponse<T>>;
|
|
23
|
+
request<T = unknown>(method: string, path: string, body?: unknown): Promise<MiniResponse<T>>;
|
|
24
|
+
close(): Promise<void>;
|
|
19
25
|
}
|
|
20
26
|
export declare function createMiniClient(options: MiniClientOptions): MiniClient;
|
|
21
27
|
export declare function miniGet<T = unknown>(url: string, headers?: Record<string, string>): Promise<MiniResponse<T>>;
|
package/dist/mini.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { request as undiciRequest } from 'undici';
|
|
1
|
+
import { Client, request as undiciRequest } from 'undici';
|
|
2
2
|
export function createMiniClient(options) {
|
|
3
3
|
const base = options.baseUrl.endsWith('/')
|
|
4
4
|
? options.baseUrl.slice(0, -1)
|
|
5
5
|
: options.baseUrl;
|
|
6
|
+
const undiciClient = new Client(base);
|
|
6
7
|
const defaultHeaders = options.headers || {};
|
|
7
8
|
const jsonHeaders = {
|
|
8
9
|
...defaultHeaders,
|
|
@@ -16,44 +17,48 @@ export function createMiniClient(options) {
|
|
|
16
17
|
arrayBuffer: () => body.arrayBuffer(),
|
|
17
18
|
blob: async () => new Blob([await body.arrayBuffer()])
|
|
18
19
|
});
|
|
20
|
+
const doRequest = async (method, path, body, useJsonHeaders = false) => {
|
|
21
|
+
const { statusCode, headers, body: resBody } = await undiciClient.request({
|
|
22
|
+
path,
|
|
23
|
+
method,
|
|
24
|
+
headers: useJsonHeaders ? jsonHeaders : defaultHeaders,
|
|
25
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
26
|
+
});
|
|
27
|
+
return wrapResponse(statusCode, headers, resBody);
|
|
28
|
+
};
|
|
19
29
|
return {
|
|
20
30
|
async get(path) {
|
|
21
|
-
|
|
22
|
-
method: 'GET',
|
|
23
|
-
headers: defaultHeaders
|
|
24
|
-
});
|
|
25
|
-
return wrapResponse(statusCode, headers, body);
|
|
31
|
+
return doRequest('GET', path);
|
|
26
32
|
},
|
|
27
33
|
async post(path, data) {
|
|
28
|
-
|
|
29
|
-
method: 'POST',
|
|
30
|
-
headers: jsonHeaders,
|
|
31
|
-
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
32
|
-
});
|
|
33
|
-
return wrapResponse(statusCode, headers, body);
|
|
34
|
+
return doRequest('POST', path, data, true);
|
|
34
35
|
},
|
|
35
36
|
async put(path, data) {
|
|
36
|
-
|
|
37
|
-
method: 'PUT',
|
|
38
|
-
headers: jsonHeaders,
|
|
39
|
-
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
40
|
-
});
|
|
41
|
-
return wrapResponse(statusCode, headers, body);
|
|
37
|
+
return doRequest('PUT', path, data, true);
|
|
42
38
|
},
|
|
43
39
|
async patch(path, data) {
|
|
44
|
-
|
|
45
|
-
method: 'PATCH',
|
|
46
|
-
headers: jsonHeaders,
|
|
47
|
-
body: data !== undefined ? JSON.stringify(data) : undefined
|
|
48
|
-
});
|
|
49
|
-
return wrapResponse(statusCode, headers, body);
|
|
40
|
+
return doRequest('PATCH', path, data, true);
|
|
50
41
|
},
|
|
51
42
|
async delete(path) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
return doRequest('DELETE', path);
|
|
44
|
+
},
|
|
45
|
+
async head(path) {
|
|
46
|
+
return doRequest('HEAD', path);
|
|
47
|
+
},
|
|
48
|
+
async options(path) {
|
|
49
|
+
return doRequest('OPTIONS', path);
|
|
50
|
+
},
|
|
51
|
+
async trace(path) {
|
|
52
|
+
return doRequest('TRACE', path);
|
|
53
|
+
},
|
|
54
|
+
async purge(path) {
|
|
55
|
+
return doRequest('PURGE', path);
|
|
56
|
+
},
|
|
57
|
+
async request(method, path, body) {
|
|
58
|
+
return doRequest(method.toUpperCase(), path, body, body !== undefined);
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
await undiciClient.close();
|
|
57
62
|
}
|
|
58
63
|
};
|
|
59
64
|
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ export { MockWebSocketServer, createMockWebSocketServer, } from './mock-websocke
|
|
|
8
8
|
export type { MockWebSocketServerOptions, MockWebSocketClient, MockWebSocketMessage, MockWebSocketStats, } from './mock-websocket-server.js';
|
|
9
9
|
export { MockSSEServer, createMockSSEServer, } from './mock-sse-server.js';
|
|
10
10
|
export type { MockSSEServerOptions, SSEEvent, MockSSEClient, MockSSEStats, } from './mock-sse-server.js';
|
|
11
|
-
export { MockHttpServer, createMockHttpServer, } from './mock-http-server.js';
|
|
12
|
-
export type { MockHttpServerOptions, MockHttpResponse, MockHttpRequest, MockHttpHandler, MockHttpStats, } from './mock-http-server.js';
|
|
11
|
+
export { MockHttpServer, createMockHttpServer, createWebhookServer, } from './mock-http-server.js';
|
|
12
|
+
export type { MockHttpServerOptions, MockHttpResponse, MockHttpRequest, MockHttpHandler, MockHttpStats, WebhookServerOptions, WebhookPayload, } from './mock-http-server.js';
|
|
13
13
|
export { MockDnsServer, } from './mock-dns-server.js';
|
|
14
14
|
export type { MockDnsServerOptions, DnsRecordType, DnsRecord, DnsMxRecord, DnsSoaRecord, DnsSrvRecord, MockDnsStats, } from './mock-dns-server.js';
|
|
15
15
|
export { MockWhoisServer, } from './mock-whois-server.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { MockUDPServer, createMockUDPServer, } from './mock-udp-server.js';
|
|
|
3
3
|
export { MockHlsServer, createMockHlsVod, createMockHlsLive, createMockHlsMultiQuality, } from './mock-hls-server.js';
|
|
4
4
|
export { MockWebSocketServer, createMockWebSocketServer, } from './mock-websocket-server.js';
|
|
5
5
|
export { MockSSEServer, createMockSSEServer, } from './mock-sse-server.js';
|
|
6
|
-
export { MockHttpServer, createMockHttpServer, } from './mock-http-server.js';
|
|
6
|
+
export { MockHttpServer, createMockHttpServer, createWebhookServer, } from './mock-http-server.js';
|
|
7
7
|
export { MockDnsServer, } from './mock-dns-server.js';
|
|
8
8
|
export { MockWhoisServer, } from './mock-whois-server.js';
|
|
9
9
|
export { MockTelnetServer, } from './mock-telnet-server.js';
|
|
@@ -81,6 +81,15 @@ export declare class MockHttpServer extends EventEmitter {
|
|
|
81
81
|
optionsRoute(path: string, handler: MockHttpResponse | MockHttpHandler, options?: {
|
|
82
82
|
times?: number;
|
|
83
83
|
}): this;
|
|
84
|
+
trace(path: string, handler: MockHttpResponse | MockHttpHandler, options?: {
|
|
85
|
+
times?: number;
|
|
86
|
+
}): this;
|
|
87
|
+
connect(path: string, handler: MockHttpResponse | MockHttpHandler, options?: {
|
|
88
|
+
times?: number;
|
|
89
|
+
}): this;
|
|
90
|
+
purge(path: string, handler: MockHttpResponse | MockHttpHandler, options?: {
|
|
91
|
+
times?: number;
|
|
92
|
+
}): this;
|
|
84
93
|
any(path: string, handler: MockHttpResponse | MockHttpHandler, options?: {
|
|
85
94
|
times?: number;
|
|
86
95
|
}): this;
|
|
@@ -97,3 +106,19 @@ export declare class MockHttpServer extends EventEmitter {
|
|
|
97
106
|
static create(options?: MockHttpServerOptions): Promise<MockHttpServer>;
|
|
98
107
|
}
|
|
99
108
|
export declare function createMockHttpServer(routes?: Record<string, MockHttpResponse>, options?: MockHttpServerOptions): Promise<MockHttpServer>;
|
|
109
|
+
export interface WebhookServerOptions extends MockHttpServerOptions {
|
|
110
|
+
log?: boolean;
|
|
111
|
+
logger?: (webhook: WebhookPayload) => void;
|
|
112
|
+
status?: 200 | 204;
|
|
113
|
+
}
|
|
114
|
+
export interface WebhookPayload {
|
|
115
|
+
id: string | null;
|
|
116
|
+
timestamp: Date;
|
|
117
|
+
method: string;
|
|
118
|
+
path: string;
|
|
119
|
+
headers: Record<string, string | string[] | undefined>;
|
|
120
|
+
body: any;
|
|
121
|
+
}
|
|
122
|
+
export declare function createWebhookServer(options?: WebhookServerOptions): Promise<MockHttpServer & {
|
|
123
|
+
webhooks: WebhookPayload[];
|
|
124
|
+
}>;
|
|
@@ -113,6 +113,15 @@ export class MockHttpServer extends EventEmitter {
|
|
|
113
113
|
optionsRoute(path, handler, options) {
|
|
114
114
|
return this.route('OPTIONS', path, handler, options);
|
|
115
115
|
}
|
|
116
|
+
trace(path, handler, options) {
|
|
117
|
+
return this.route('TRACE', path, handler, options);
|
|
118
|
+
}
|
|
119
|
+
connect(path, handler, options) {
|
|
120
|
+
return this.route('CONNECT', path, handler, options);
|
|
121
|
+
}
|
|
122
|
+
purge(path, handler, options) {
|
|
123
|
+
return this.route('PURGE', path, handler, options);
|
|
124
|
+
}
|
|
116
125
|
any(path, handler, options) {
|
|
117
126
|
return this.route('*', path, handler, options);
|
|
118
127
|
}
|
|
@@ -296,3 +305,39 @@ export async function createMockHttpServer(routes, options) {
|
|
|
296
305
|
await server.start();
|
|
297
306
|
return server;
|
|
298
307
|
}
|
|
308
|
+
export async function createWebhookServer(options = {}) {
|
|
309
|
+
const { log = true, logger, status = 204, ...serverOptions } = options;
|
|
310
|
+
const server = new MockHttpServer(serverOptions);
|
|
311
|
+
const webhooks = [];
|
|
312
|
+
const handleWebhook = (req, id) => {
|
|
313
|
+
const payload = {
|
|
314
|
+
id,
|
|
315
|
+
timestamp: new Date(),
|
|
316
|
+
method: req.method,
|
|
317
|
+
path: req.path,
|
|
318
|
+
headers: req.headers,
|
|
319
|
+
body: req.body,
|
|
320
|
+
};
|
|
321
|
+
webhooks.push(payload);
|
|
322
|
+
if (logger) {
|
|
323
|
+
logger(payload);
|
|
324
|
+
}
|
|
325
|
+
else if (log) {
|
|
326
|
+
const idStr = id ? ` [${id}]` : '';
|
|
327
|
+
console.log(`\n📥 Webhook received${idStr} at ${payload.timestamp.toISOString()}`);
|
|
328
|
+
console.log(` Path: ${req.path}`);
|
|
329
|
+
if (req.body !== undefined) {
|
|
330
|
+
console.log(` Body:`, typeof req.body === 'object' ? JSON.stringify(req.body, null, 2) : req.body);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { status };
|
|
334
|
+
};
|
|
335
|
+
server.any('/', (req) => handleWebhook(req, null));
|
|
336
|
+
server.any('/:id', (req) => {
|
|
337
|
+
const match = req.path.match(/^\/([^/]+)$/);
|
|
338
|
+
const id = match ? match[1] : null;
|
|
339
|
+
return handleWebhook(req, id);
|
|
340
|
+
});
|
|
341
|
+
await server.start();
|
|
342
|
+
return Object.assign(server, { webhooks });
|
|
343
|
+
}
|