docorbit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +660 -0
  3. package/apps/cli/bin/docorbit.js +8 -0
  4. package/apps/cli/src/commands/add.ts +44 -0
  5. package/apps/cli/src/commands/api.ts +38 -0
  6. package/apps/cli/src/commands/context.ts +47 -0
  7. package/apps/cli/src/commands/dashboard.ts +55 -0
  8. package/apps/cli/src/commands/diff.ts +30 -0
  9. package/apps/cli/src/commands/evaluate.ts +133 -0
  10. package/apps/cli/src/commands/examples.ts +39 -0
  11. package/apps/cli/src/commands/export.ts +89 -0
  12. package/apps/cli/src/commands/impact.ts +31 -0
  13. package/apps/cli/src/commands/init.ts +69 -0
  14. package/apps/cli/src/commands/inspect.ts +30 -0
  15. package/apps/cli/src/commands/mcp.ts +72 -0
  16. package/apps/cli/src/commands/pitfalls.ts +38 -0
  17. package/apps/cli/src/commands/recipes.ts +35 -0
  18. package/apps/cli/src/commands/search.ts +48 -0
  19. package/apps/cli/src/commands/update.ts +73 -0
  20. package/apps/cli/src/commands/verify.ts +48 -0
  21. package/apps/cli/src/formatters/colors.ts +23 -0
  22. package/apps/cli/src/formatters/inspection.ts +102 -0
  23. package/apps/cli/src/formatters/knowledge.ts +272 -0
  24. package/apps/cli/src/formatters/retrieval.ts +74 -0
  25. package/apps/cli/src/formatters/terminal.ts +6 -0
  26. package/apps/cli/src/formatters/verification.ts +126 -0
  27. package/apps/cli/src/index.ts +409 -0
  28. package/bin/docorbit.js +8 -0
  29. package/package.json +46 -0
  30. package/packages/core/src/dashboard/server.ts +314 -0
  31. package/packages/core/src/dashboard/ui.ts +586 -0
  32. package/packages/core/src/implementation-service.ts +451 -0
  33. package/packages/core/src/index.ts +7 -0
  34. package/packages/core/src/inspector.ts +71 -0
  35. package/packages/core/src/pipeline.ts +331 -0
  36. package/packages/crawler/src/config.ts +12 -0
  37. package/packages/crawler/src/fetcher.ts +185 -0
  38. package/packages/crawler/src/index.ts +2 -0
  39. package/packages/discovery/src/index.ts +31 -0
  40. package/packages/discovery/src/provider.ts +47 -0
  41. package/packages/discovery/src/providers/generic.ts +98 -0
  42. package/packages/discovery/src/providers/github.ts +61 -0
  43. package/packages/discovery/src/providers/llms-txt.ts +73 -0
  44. package/packages/discovery/src/providers/markdown.ts +48 -0
  45. package/packages/discovery/src/providers/openapi.ts +91 -0
  46. package/packages/discovery/src/providers/sitemap.ts +62 -0
  47. package/packages/discovery/src/providers/skill.ts +54 -0
  48. package/packages/discovery/src/ranker.ts +123 -0
  49. package/packages/evaluation/src/dataset.ts +963 -0
  50. package/packages/evaluation/src/index.ts +8 -0
  51. package/packages/evaluation/src/runner.ts +241 -0
  52. package/packages/evaluation/src/strategies/context7-runner.ts +269 -0
  53. package/packages/evaluation/src/strategies/docorbit-runner.ts +228 -0
  54. package/packages/evaluation/src/strategies/firecrawl-runner.ts +172 -0
  55. package/packages/evaluation/src/strategies/web-search-runner.ts +194 -0
  56. package/packages/evaluation/src/types.ts +34 -0
  57. package/packages/evaluation/src/version-matcher.ts +73 -0
  58. package/packages/export/src/agents-md.ts +200 -0
  59. package/packages/export/src/claude-md.ts +141 -0
  60. package/packages/export/src/docs-map.ts +150 -0
  61. package/packages/export/src/index.ts +6 -0
  62. package/packages/export/src/llms-txt.ts +96 -0
  63. package/packages/export/src/service.ts +250 -0
  64. package/packages/export/src/skill-md.ts +128 -0
  65. package/packages/mcp/src/index.ts +46 -0
  66. package/packages/mcp/src/resources/index.ts +189 -0
  67. package/packages/mcp/src/server.ts +278 -0
  68. package/packages/mcp/src/tools/analyze-impact.ts +74 -0
  69. package/packages/mcp/src/tools/check-api.ts +86 -0
  70. package/packages/mcp/src/tools/diff-docs.ts +68 -0
  71. package/packages/mcp/src/tools/export-context.ts +73 -0
  72. package/packages/mcp/src/tools/find-api.ts +99 -0
  73. package/packages/mcp/src/tools/find-example.ts +100 -0
  74. package/packages/mcp/src/tools/find-pitfall.ts +94 -0
  75. package/packages/mcp/src/tools/find-recipe.ts +98 -0
  76. package/packages/mcp/src/tools/get-doc.ts +130 -0
  77. package/packages/mcp/src/tools/get-docs-map.ts +64 -0
  78. package/packages/mcp/src/tools/get-version.ts +118 -0
  79. package/packages/mcp/src/tools/implementation-context.ts +88 -0
  80. package/packages/mcp/src/tools/index.ts +59 -0
  81. package/packages/mcp/src/tools/list-sources.ts +85 -0
  82. package/packages/mcp/src/tools/search-docs.ts +123 -0
  83. package/packages/mcp/src/tools/types.ts +28 -0
  84. package/packages/mcp/src/transports/http.ts +256 -0
  85. package/packages/mcp/src/transports/stdio.ts +105 -0
  86. package/packages/mcp/src/transports/types.ts +6 -0
  87. package/packages/mcp/src/types.ts +102 -0
  88. package/packages/normalizer/src/example-indexer.ts +240 -0
  89. package/packages/normalizer/src/html.ts +253 -0
  90. package/packages/normalizer/src/index.ts +8 -0
  91. package/packages/normalizer/src/llms.ts +83 -0
  92. package/packages/normalizer/src/openapi/endpoint-parser.ts +406 -0
  93. package/packages/normalizer/src/openapi/schema-resolver.ts +111 -0
  94. package/packages/normalizer/src/openapi.ts +2 -0
  95. package/packages/normalizer/src/page.ts +184 -0
  96. package/packages/normalizer/src/pitfall-extractor.ts +190 -0
  97. package/packages/normalizer/src/slicer.ts +455 -0
  98. package/packages/retrieval/src/engine.ts +120 -0
  99. package/packages/retrieval/src/index.ts +7 -0
  100. package/packages/retrieval/src/intent.ts +43 -0
  101. package/packages/retrieval/src/packer.ts +145 -0
  102. package/packages/retrieval/src/recipe-engine.ts +313 -0
  103. package/packages/retrieval/src/scorer.ts +139 -0
  104. package/packages/retrieval/src/weights.ts +31 -0
  105. package/packages/security/src/annotations.ts +112 -0
  106. package/packages/security/src/index.ts +2 -0
  107. package/packages/security/src/ssrf.ts +153 -0
  108. package/packages/shared/src/errors.ts +53 -0
  109. package/packages/shared/src/hashing.ts +23 -0
  110. package/packages/shared/src/index.ts +3 -0
  111. package/packages/shared/src/types.ts +881 -0
  112. package/packages/storage/src/db.ts +72 -0
  113. package/packages/storage/src/index.ts +11 -0
  114. package/packages/storage/src/interfaces.ts +115 -0
  115. package/packages/storage/src/repositories/api-repository.ts +219 -0
  116. package/packages/storage/src/repositories/chunk-repository.ts +316 -0
  117. package/packages/storage/src/repositories/example-repository.ts +206 -0
  118. package/packages/storage/src/repositories/page-repository.ts +205 -0
  119. package/packages/storage/src/repositories/pitfall-repository.ts +188 -0
  120. package/packages/storage/src/repositories/source-repository.ts +205 -0
  121. package/packages/storage/src/repository.ts +256 -0
  122. package/packages/storage/src/schema.ts +269 -0
  123. package/packages/storage/src/search-tokens.ts +28 -0
  124. package/packages/verification/src/diff-engine.ts +258 -0
  125. package/packages/verification/src/extractor.ts +339 -0
  126. package/packages/verification/src/impact-scanner.ts +203 -0
  127. package/packages/verification/src/index.ts +5 -0
  128. package/packages/verification/src/services.ts +238 -0
  129. package/packages/verification/src/verifier.ts +375 -0
  130. package/packages/workspace/src/detector.ts +143 -0
  131. package/packages/workspace/src/ecosystems/cargo.ts +84 -0
  132. package/packages/workspace/src/ecosystems/composer.ts +42 -0
  133. package/packages/workspace/src/ecosystems/go.ts +54 -0
  134. package/packages/workspace/src/ecosystems/index.ts +34 -0
  135. package/packages/workspace/src/ecosystems/maven.ts +34 -0
  136. package/packages/workspace/src/ecosystems/npm.ts +83 -0
  137. package/packages/workspace/src/ecosystems/pub.ts +40 -0
  138. package/packages/workspace/src/ecosystems/pypi.ts +100 -0
  139. package/packages/workspace/src/ecosystems/rubygems.ts +30 -0
  140. package/packages/workspace/src/ecosystems/types.ts +18 -0
  141. package/packages/workspace/src/index.ts +5 -0
  142. package/packages/workspace/src/lockfile.ts +194 -0
  143. package/packages/workspace/src/resolver.ts +234 -0
  144. package/packages/workspace/src/semver.ts +259 -0
@@ -0,0 +1,331 @@
1
+ import type {
2
+ DiscoveredSource,
3
+ NormalizedPage,
4
+ CrawlerConfig,
5
+ CrawlPolicy,
6
+ Target,
7
+ SourcePurpose,
8
+ } from '../../shared/src/index.ts';
9
+ import { validateTargetUrl } from '../../security/src/index.ts';
10
+ import { SecureFetcher, DEFAULT_CRAWLER_CONFIG } from '../../crawler/src/index.ts';
11
+ import { createDefaultDiscoveryCoordinator, rankSources } from '../../discovery/src/index.ts';
12
+ import {
13
+ buildNormalizedPage,
14
+ parseLlmsTxt,
15
+ isValidLlmsTxt,
16
+ slicePageIntoChunks,
17
+ parseOpenApiEndpoints,
18
+ detectOpenApiSpec,
19
+ extractIndexedExamples,
20
+ extractPitfalls,
21
+ } from '../../normalizer/src/index.ts';
22
+ import { DocOrbitRepository } from '../../storage/src/index.ts';
23
+
24
+ export interface IngestionOptions {
25
+ crawlerConfig?: Partial<CrawlerConfig>;
26
+ crawlPolicy?: Partial<CrawlPolicy>;
27
+ allowLocalhostForTesting?: boolean;
28
+ }
29
+
30
+ export interface IngestionResult {
31
+ targetUrl: string;
32
+ target?: Target;
33
+ primarySourceId: string;
34
+ sourcesDiscovered: DiscoveredSource[];
35
+ selectedSources: Array<{
36
+ purpose: SourcePurpose;
37
+ sourceId: string;
38
+ }>;
39
+ pages: NormalizedPage[];
40
+ pagesDiscovered: number;
41
+ pagesFetched: number;
42
+ pagesStored: number;
43
+ snapshotId: string;
44
+ warnings: string[];
45
+ errors: Array<{
46
+ url: string;
47
+ error: string;
48
+ }>;
49
+ durationMs: number;
50
+ stats: {
51
+ totalPages: number;
52
+ totalBytes: number;
53
+ totalEstimatedTokens: number;
54
+ totalCodeExamples: number;
55
+ machineReadableSources: number;
56
+ totalChunks: number;
57
+ };
58
+ }
59
+
60
+
61
+ export class IngestionPipeline {
62
+ private repository: DocOrbitRepository;
63
+ private fetcher: SecureFetcher;
64
+ private config: CrawlerConfig;
65
+ private policy: CrawlPolicy;
66
+ private allowLocalhostForTesting: boolean;
67
+
68
+ constructor(repository: DocOrbitRepository, options: IngestionOptions = {}) {
69
+ this.repository = repository;
70
+ this.config = {
71
+ ...DEFAULT_CRAWLER_CONFIG,
72
+ ...options.crawlerConfig,
73
+ };
74
+ this.allowLocalhostForTesting = options.allowLocalhostForTesting ?? false;
75
+ this.policy = {
76
+ purpose: 'conceptual',
77
+ maxPages: this.config.maxPages,
78
+ maxDepth: this.config.maxDepth || 2,
79
+ followLlmsReferences: true,
80
+ followExternalDomains: false,
81
+ ...options.crawlPolicy,
82
+ };
83
+ this.fetcher = new SecureFetcher({
84
+ timeoutMs: this.config.timeoutMs,
85
+ maxBytes: this.config.maxBytesPerResponse,
86
+ maxRedirects: this.config.maxRedirects,
87
+ userAgent: this.config.userAgent,
88
+ allowLocalhostForTesting: this.allowLocalhostForTesting,
89
+ });
90
+ }
91
+
92
+ async ingest(targetUrl: string): Promise<IngestionResult> {
93
+ const startTime = Date.now();
94
+
95
+ // 1. SSRF & Protocol validation before making any discovery probes
96
+ await validateTargetUrl(targetUrl, {
97
+ allowLocalhostForTesting: this.allowLocalhostForTesting,
98
+ });
99
+
100
+ const coordinator = createDefaultDiscoveryCoordinator();
101
+ const discovered = await coordinator.discoverAll(targetUrl, this.fetcher);
102
+
103
+ const sourceIds: Record<string, string> = {};
104
+ for (const s of discovered) {
105
+ const id = this.repository.saveSource(s);
106
+ sourceIds[s.url] = id;
107
+ }
108
+
109
+ const conceptualRank = rankSources(discovered, 'conceptual');
110
+ const apiRank = rankSources(discovered, 'api');
111
+
112
+ const primarySource = conceptualRank.recommended || discovered[0];
113
+ const primarySourceId = primarySource
114
+ ? (sourceIds[primarySource.url] || this.repository.saveSource(primarySource))
115
+ : this.repository.saveSource({
116
+ url: targetUrl,
117
+ type: 'web',
118
+ discoveredBy: 'direct',
119
+ status: 'valid',
120
+ confidence: 1.0,
121
+ authority: 'official',
122
+ machineReadable: false,
123
+ });
124
+
125
+ const purposes: SourcePurpose[] = ['navigation', 'conceptual', 'api', 'examples', 'implementation'];
126
+ const selectedSources = purposes.map(p => {
127
+ const ranked = rankSources(discovered, p);
128
+ return {
129
+ purpose: p,
130
+ sourceId: ranked.recommended ? (sourceIds[ranked.recommended.url] || primarySourceId) : primarySourceId,
131
+ };
132
+ });
133
+
134
+ const queue: Array<{ url: string; depth: number }> = [];
135
+ const visited = new Set<string>();
136
+ const ingestedPages: NormalizedPage[] = [];
137
+ const warnings: string[] = [];
138
+ const errors: Array<{ url: string; error: string }> = [];
139
+ let pagesDiscovered = 0;
140
+
141
+ const enqueue = (url: string, depth: number) => {
142
+ try {
143
+ const parsed = new URL(url);
144
+ const normalized = `${parsed.origin}${parsed.pathname}`;
145
+ pagesDiscovered++;
146
+ if (depth > this.policy.maxDepth) return;
147
+ if (!visited.has(normalized) && queue.length < this.policy.maxPages) {
148
+ queue.push({ url: normalized, depth });
149
+ }
150
+ } catch {
151
+ // Ignore
152
+ }
153
+ };
154
+
155
+ // 1. Prioritize targetUrl as the primary entry point (depth: 0)
156
+ enqueue(targetUrl, 0);
157
+
158
+ // 2. If an OpenAPI spec is available, enqueue it (depth: 0)
159
+ if (apiRank.recommended && apiRank.recommended.type === 'openapi' && apiRank.recommended.url !== targetUrl) {
160
+ enqueue(apiRank.recommended.url, 0);
161
+ }
162
+
163
+ // 3. If llms.txt or llms-full.txt is available, enqueue it (depth: 0)
164
+ const llmsFull = discovered.find(s => s.type === 'llms_full_txt' && s.status === 'valid');
165
+ const llms = discovered.find(s => s.type === 'llms_txt' && s.status === 'valid');
166
+
167
+ if (llmsFull && llmsFull.url !== targetUrl) {
168
+ enqueue(llmsFull.url, 0);
169
+ } else if (llms && llms.url !== targetUrl) {
170
+ enqueue(llms.url, 0);
171
+ }
172
+
173
+ if (primarySource && primarySource.url !== targetUrl) {
174
+ enqueue(primarySource.url, 0);
175
+ }
176
+
177
+ const targetOrigin = new URL(targetUrl).origin;
178
+
179
+ while (queue.length > 0 && ingestedPages.length < this.policy.maxPages) {
180
+ const item = queue.shift()!;
181
+ const currentUrl = item.url;
182
+ const currentDepth = item.depth;
183
+
184
+ if (visited.has(currentUrl)) continue;
185
+ visited.add(currentUrl);
186
+
187
+ try {
188
+ const res = await this.fetcher.fetch(currentUrl);
189
+ if (res.status < 200 || res.status >= 300) {
190
+ errors.push({ url: currentUrl, error: `HTTP status ${res.status}` });
191
+ continue;
192
+ }
193
+
194
+ const page = buildNormalizedPage({
195
+ sourceId: primarySourceId,
196
+ url: res.finalUrl || currentUrl,
197
+ rawContent: res.body,
198
+ contentType: res.contentType,
199
+ sourceUrl: primarySource?.url || targetUrl,
200
+ targetUrl,
201
+ discoveredBy: primarySource?.discoveredBy || 'direct',
202
+ fetchedAt: new Date().toISOString(),
203
+ });
204
+
205
+ this.repository.savePage(page);
206
+ ingestedPages.push(page);
207
+
208
+ // Subpage link discovery
209
+ if (isValidLlmsTxt(res.body)) {
210
+ if (this.policy.followLlmsReferences && currentDepth < this.policy.maxDepth) {
211
+ const llmsDoc = parseLlmsTxt(res.body, currentUrl);
212
+ for (const sec of llmsDoc.sections) {
213
+ for (const lnk of sec.links) {
214
+ if (ingestedPages.length + queue.length < this.policy.maxPages) {
215
+ try {
216
+ const parsedLnk = new URL(lnk.url);
217
+ if (this.policy.followExternalDomains || parsedLnk.origin === targetOrigin) {
218
+ enqueue(lnk.url, currentDepth + 1);
219
+ }
220
+ } catch {
221
+ // Ignore
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ } else {
228
+ if (currentDepth < this.policy.maxDepth) {
229
+ for (const lnk of page.links) {
230
+ if (ingestedPages.length + queue.length < this.policy.maxPages) {
231
+ try {
232
+ const linkUrl = new URL(lnk.url);
233
+ if (this.policy.followExternalDomains || linkUrl.origin === targetOrigin) {
234
+ enqueue(lnk.url, currentDepth + 1);
235
+ }
236
+ } catch {
237
+ // Ignore
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+ } catch (err: unknown) {
244
+ errors.push({ url: currentUrl, error: err instanceof Error ? err.message : String(err) });
245
+ }
246
+ }
247
+
248
+ const snapshotId = this.repository.createSnapshot(primarySourceId, {
249
+ targetUrl,
250
+ pageCount: ingestedPages.length,
251
+ ingestedAt: new Date().toISOString(),
252
+ });
253
+
254
+ // Milestone 2: Semantic slicing of pages into standalone DocumentChunks
255
+ let totalChunks = 0;
256
+ for (const p of ingestedPages) {
257
+ const slicingResult = slicePageIntoChunks(p, snapshotId);
258
+ this.repository.saveChunks(
259
+ slicingResult.chunks,
260
+ slicingResult.relationships,
261
+ slicingResult.codeSnippets,
262
+ slicingResult.symbols
263
+ );
264
+ totalChunks += slicingResult.chunks.length;
265
+
266
+ // Milestone 4: API Endpoints
267
+ if (p.openApiSummary || detectOpenApiSpec(p.content, p.url)) {
268
+ const endpoints = parseOpenApiEndpoints(p.content, p.id, snapshotId, p.url, p.docVersion);
269
+ if (endpoints.length > 0) {
270
+ this.repository.saveApiEndpoints(endpoints);
271
+ }
272
+ }
273
+
274
+ // Milestone 4: Indexed Examples
275
+ const examples = extractIndexedExamples(p, slicingResult.chunks, primarySource?.authority || 'official', p.docVersion, snapshotId);
276
+ if (examples.length > 0) {
277
+ this.repository.saveIndexedExamples(examples);
278
+ }
279
+
280
+ // Milestone 4: Pitfalls
281
+ const pitfalls = extractPitfalls(p, slicingResult.chunks, p.docVersion, snapshotId);
282
+ if (pitfalls.length > 0) {
283
+ this.repository.savePitfalls(pitfalls);
284
+ }
285
+ }
286
+
287
+ const durationMs = Date.now() - startTime;
288
+
289
+ let totalBytes = 0;
290
+ let totalEstimatedTokens = 0;
291
+ let totalCodeExamples = 0;
292
+
293
+ for (const p of ingestedPages) {
294
+ totalBytes += p.rawBytes;
295
+ totalEstimatedTokens += p.estimatedTokens;
296
+ totalCodeExamples += p.codeExamples.length;
297
+ if (p.securityAnnotations.length > 0) {
298
+ warnings.push(`Page "${p.url}" generated ${p.securityAnnotations.length} security alert(s).`);
299
+ }
300
+ }
301
+
302
+ return {
303
+ targetUrl,
304
+ target: {
305
+ type: 'url',
306
+ value: targetUrl,
307
+ normalizedUrl: targetUrl,
308
+ },
309
+ primarySourceId,
310
+ sourcesDiscovered: discovered,
311
+ selectedSources,
312
+ pages: ingestedPages,
313
+ pagesDiscovered,
314
+ pagesFetched: visited.size,
315
+ pagesStored: ingestedPages.length,
316
+ snapshotId,
317
+ warnings,
318
+ errors,
319
+ durationMs,
320
+ stats: {
321
+ totalPages: ingestedPages.length,
322
+ totalBytes,
323
+ totalEstimatedTokens,
324
+ totalCodeExamples,
325
+ machineReadableSources: discovered.filter(s => s.status === 'valid' && s.machineReadable).length,
326
+ totalChunks,
327
+ },
328
+ };
329
+ }
330
+ }
331
+
@@ -0,0 +1,12 @@
1
+ import type { CrawlerConfig } from '../../shared/src/index.ts';
2
+
3
+ export const DEFAULT_CRAWLER_CONFIG: CrawlerConfig = {
4
+ maxPages: 50,
5
+ maxDepth: 3,
6
+ maxBytesPerResponse: 10 * 1024 * 1024, // 10MB
7
+ timeoutMs: 10000, // 10 seconds
8
+ maxRedirects: 5,
9
+ concurrency: 5,
10
+ allowedProtocols: ['http:', 'https:'],
11
+ userAgent: 'DocOrbit/1.0 (+https://github.com/docorbit/docorbit)',
12
+ };
@@ -0,0 +1,185 @@
1
+ import {
2
+ FetchTimeoutError,
3
+ PayloadTooLargeError,
4
+ DocOrbitError,
5
+ } from '../../shared/src/index.ts';
6
+ import { validateTargetUrl } from '../../security/src/index.ts';
7
+ import type { SsrfValidationOptions } from '../../security/src/index.ts';
8
+ import { DEFAULT_CRAWLER_CONFIG } from './config.ts';
9
+
10
+ export interface FetchResult {
11
+ url: string;
12
+ finalUrl: string;
13
+ status: number;
14
+ statusText: string;
15
+ headers: Record<string, string>;
16
+ body: string;
17
+ contentType: string;
18
+ bytesRead: number;
19
+ }
20
+
21
+ export interface FetchOptions extends SsrfValidationOptions {
22
+ timeoutMs?: number;
23
+ maxBytes?: number;
24
+ maxRedirects?: number;
25
+ userAgent?: string;
26
+ headers?: Record<string, string>;
27
+ method?: string;
28
+ fetchFn?: typeof fetch;
29
+ allowCrossDomainRedirects?: boolean;
30
+ }
31
+
32
+ export class SecureFetcher {
33
+ private timeoutMs: number;
34
+ private maxBytes: number;
35
+ private maxRedirects: number;
36
+ private userAgent: string;
37
+ private allowLocalhostForTesting: boolean;
38
+ private fetchFn: typeof fetch;
39
+
40
+ constructor(defaults: Partial<FetchOptions> = {}) {
41
+ this.timeoutMs = defaults.timeoutMs ?? DEFAULT_CRAWLER_CONFIG.timeoutMs;
42
+ this.maxBytes = defaults.maxBytes ?? DEFAULT_CRAWLER_CONFIG.maxBytesPerResponse;
43
+ this.maxRedirects = defaults.maxRedirects ?? DEFAULT_CRAWLER_CONFIG.maxRedirects;
44
+ this.userAgent = defaults.userAgent ?? DEFAULT_CRAWLER_CONFIG.userAgent;
45
+ this.allowLocalhostForTesting = defaults.allowLocalhostForTesting ?? false;
46
+ this.fetchFn = defaults.fetchFn ?? globalThis.fetch;
47
+ }
48
+
49
+ async fetch(urlStr: string, options: FetchOptions = {}): Promise<FetchResult> {
50
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
51
+ const maxBytes = options.maxBytes ?? this.maxBytes;
52
+ const maxRedirects = options.maxRedirects ?? this.maxRedirects;
53
+ const userAgent = options.userAgent ?? this.userAgent;
54
+ const method = options.method ?? 'GET';
55
+ const allowLocalhost = options.allowLocalhostForTesting ?? this.allowLocalhostForTesting;
56
+ const activeFetch = options.fetchFn ?? this.fetchFn;
57
+
58
+ let currentUrl = urlStr;
59
+ let redirectCount = 0;
60
+ const seenRedirects = new Set<string>();
61
+ seenRedirects.add(currentUrl);
62
+
63
+ while (redirectCount <= maxRedirects) {
64
+ // 1. SSRF & Protocol validation before making the hop
65
+ await validateTargetUrl(currentUrl, {
66
+ allowLocalhostForTesting: allowLocalhost,
67
+ });
68
+
69
+ const controller = new AbortController();
70
+ const timeoutId = setTimeout(() => {
71
+ controller.abort(new FetchTimeoutError(`Request timed out after ${timeoutMs}ms`, timeoutMs));
72
+ }, timeoutMs);
73
+
74
+ try {
75
+ const reqHeaders: Record<string, string> = {
76
+ 'User-Agent': userAgent,
77
+ Accept: 'text/html,application/xhtml+xml,text/plain,text/markdown,application/json,*/*',
78
+ ...options.headers,
79
+ };
80
+
81
+ const res = await activeFetch(currentUrl, {
82
+ method,
83
+ headers: reqHeaders,
84
+ redirect: 'manual',
85
+ signal: controller.signal,
86
+ });
87
+
88
+ // 2. Handle HTTP redirects (301, 302, 303, 307, 308)
89
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
90
+ const location = res.headers.get('location');
91
+ if (!location) {
92
+ throw new DocOrbitError(`Redirect response (${res.status}) missing Location header.`);
93
+ }
94
+
95
+ redirectCount++;
96
+ if (redirectCount > maxRedirects) {
97
+ throw new DocOrbitError(`Exceeded maximum allowed redirects (${maxRedirects}).`);
98
+ }
99
+
100
+ const nextUrl = new URL(location, currentUrl).href;
101
+
102
+ // Check for redirect loop
103
+ if (seenRedirects.has(nextUrl)) {
104
+ throw new DocOrbitError(`Redirect loop detected: "${nextUrl}" already visited in redirect chain.`);
105
+ }
106
+ seenRedirects.add(nextUrl);
107
+
108
+ // Enforce domain/origin policy
109
+ if (options.allowCrossDomainRedirects === false) {
110
+ const origOrigin = new URL(urlStr).origin;
111
+ const nextOrigin = new URL(nextUrl).origin;
112
+ if (origOrigin !== nextOrigin) {
113
+ throw new DocOrbitError(`Cross-origin redirect from "${origOrigin}" to "${nextOrigin}" prohibited by policy.`);
114
+ }
115
+ }
116
+
117
+ currentUrl = nextUrl;
118
+ continue;
119
+ }
120
+
121
+ // 3. Extract response headers
122
+ const headers: Record<string, string> = {};
123
+ res.headers.forEach((val, key) => {
124
+ headers[key.toLowerCase()] = val;
125
+ });
126
+
127
+ const contentType = headers['content-type'] || 'application/octet-stream';
128
+
129
+ // 4. Stream response body with byte counting
130
+ let bytesRead = 0;
131
+ const chunks: Uint8Array[] = [];
132
+
133
+ if (res.body) {
134
+ const reader = res.body.getReader();
135
+ try {
136
+ while (true) {
137
+ const { done, value } = await reader.read();
138
+ if (done) break;
139
+ if (value) {
140
+ bytesRead += value.length;
141
+ if (bytesRead > maxBytes) {
142
+ controller.abort();
143
+ throw new PayloadTooLargeError(
144
+ `Response exceeded maximum allowed size of ${maxBytes} bytes (read ${bytesRead} bytes).`,
145
+ bytesRead,
146
+ maxBytes
147
+ );
148
+ }
149
+ chunks.push(value);
150
+ }
151
+ }
152
+ } finally {
153
+ reader.releaseLock();
154
+ }
155
+ }
156
+
157
+ const totalBuffer = Buffer.concat(chunks);
158
+ const bodyText = totalBuffer.toString('utf-8');
159
+
160
+ return {
161
+ url: urlStr,
162
+ finalUrl: currentUrl,
163
+ status: res.status,
164
+ statusText: res.statusText,
165
+ headers,
166
+ body: bodyText,
167
+ contentType,
168
+ bytesRead,
169
+ };
170
+ } catch (err: unknown) {
171
+ if (err instanceof DocOrbitError) throw err;
172
+ if (controller.signal.aborted) {
173
+ const reason = controller.signal.reason;
174
+ if (reason instanceof DocOrbitError) throw reason;
175
+ throw new FetchTimeoutError(`Request timed out after ${timeoutMs}ms`, timeoutMs);
176
+ }
177
+ throw new DocOrbitError(`Fetch failure for "${currentUrl}": ${err instanceof Error ? err.message : String(err)}`);
178
+ } finally {
179
+ clearTimeout(timeoutId);
180
+ }
181
+ }
182
+
183
+ throw new DocOrbitError(`Exceeded maximum allowed redirects (${maxRedirects}).`);
184
+ }
185
+ }
@@ -0,0 +1,2 @@
1
+ export * from './config.ts';
2
+ export * from './fetcher.ts';
@@ -0,0 +1,31 @@
1
+ import { DiscoveryCoordinator } from './provider.ts';
2
+ import { LlmsTxtProvider } from './providers/llms-txt.ts';
3
+ import { OpenApiProvider } from './providers/openapi.ts';
4
+ import { MarkdownProvider } from './providers/markdown.ts';
5
+ import { SitemapProvider } from './providers/sitemap.ts';
6
+ import { GithubProvider } from './providers/github.ts';
7
+ import { SkillProvider } from './providers/skill.ts';
8
+ import { GenericWebProvider } from './providers/generic.ts';
9
+
10
+ export * from './provider.ts';
11
+ export * from './ranker.ts';
12
+ export * from './providers/llms-txt.ts';
13
+ export * from './providers/openapi.ts';
14
+ export * from './providers/markdown.ts';
15
+ export * from './providers/sitemap.ts';
16
+ export * from './providers/github.ts';
17
+ export * from './providers/skill.ts';
18
+ export * from './providers/generic.ts';
19
+
20
+ export function createDefaultDiscoveryCoordinator(): DiscoveryCoordinator {
21
+ const coordinator = new DiscoveryCoordinator();
22
+ coordinator
23
+ .register(new LlmsTxtProvider())
24
+ .register(new OpenApiProvider())
25
+ .register(new MarkdownProvider())
26
+ .register(new SitemapProvider())
27
+ .register(new SkillProvider())
28
+ .register(new GithubProvider())
29
+ .register(new GenericWebProvider());
30
+ return coordinator;
31
+ }
@@ -0,0 +1,47 @@
1
+ import type { DiscoveredSource } from '../../shared/src/index.ts';
2
+ import { SecureFetcher } from '../../crawler/src/index.ts';
3
+
4
+ export interface DiscoveryProvider {
5
+ name: string;
6
+ discover(targetUrl: string, fetcher: SecureFetcher): Promise<DiscoveredSource[]>;
7
+ }
8
+
9
+ export class DiscoveryCoordinator {
10
+ private providers: DiscoveryProvider[] = [];
11
+
12
+ register(provider: DiscoveryProvider): this {
13
+ this.providers.push(provider);
14
+ return this;
15
+ }
16
+
17
+ getProviders(): DiscoveryProvider[] {
18
+ return [...this.providers];
19
+ }
20
+
21
+ async discoverAll(targetUrl: string, fetcher: SecureFetcher): Promise<DiscoveredSource[]> {
22
+ const results = await Promise.allSettled(
23
+ this.providers.map(p => p.discover(targetUrl, fetcher))
24
+ );
25
+
26
+ const allSources: DiscoveredSource[] = [];
27
+ for (const res of results) {
28
+ if (res.status === 'fulfilled') {
29
+ allSources.push(...res.value);
30
+ }
31
+ }
32
+
33
+ // Deduplicate sources by normalized URL
34
+ const seen = new Set<string>();
35
+ const deduped: DiscoveredSource[] = [];
36
+
37
+ for (const s of allSources) {
38
+ const normalizedUrl = s.url.replace(/\/$/, '').toLowerCase();
39
+ if (!seen.has(normalizedUrl)) {
40
+ seen.add(normalizedUrl);
41
+ deduped.push(s);
42
+ }
43
+ }
44
+
45
+ return deduped;
46
+ }
47
+ }