@nbtca/docs 0.3.2 → 0.3.3

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/README.md CHANGED
@@ -37,6 +37,7 @@ const matches = await docs.search('repair', { pathPrefix: 'repair' });
37
37
  | `cacheTtlMs.dir` | `300000` | Directory and tree cache TTL |
38
38
  | `cacheTtlMs.file` | `600000` | File cache TTL |
39
39
  | `store` | none | Persistent cache, see below |
40
+ | `mirror` | none | Mirror base URL, see below |
40
41
 
41
42
  ### `docs.listDir(path?)`
42
43
 
@@ -73,6 +74,13 @@ tree under `tree` and file content under `blob-<sha>`, where `<sha>` is the Git
73
74
  serves stored content only when its hash matches the blob id in the last known tree, so a changed
74
75
  file is always refetched. Store errors and corrupt entries are ignored; eviction is up to the store.
75
76
 
77
+ ### `mirror`
78
+
79
+ A base URL tried before GitHub by `listAll` and `getFile`, such as
80
+ `https://docs.nbtca.space/docs-api`. It serves `index.json` in the shape of GitHub's recursive tree
81
+ response and each file at `raw/<path>`. Any mirror failure, invalid or truncated index, or wait past
82
+ 5 seconds falls back to GitHub. The GitHub token is never sent to the mirror.
83
+
76
84
  ### `docs.search(query, options?)`
77
85
 
78
86
  Searches paths, titles, summaries, Markdown text, and semantic component attributes. Results are
package/dist/client.js CHANGED
@@ -31,6 +31,7 @@ const SKIP = new Set([
31
31
  'docs',
32
32
  ]);
33
33
  const TREE_KEY = 'tree';
34
+ const MIRROR_TIMEOUT_MS = 5000;
34
35
  const SHA = /^[0-9a-f]{40}$/;
35
36
  const SEARCH_CONCURRENCY = 6;
36
37
  const SEARCH_RESULT_LIMIT = 20;
@@ -189,6 +190,15 @@ function assertBranchRef(value) {
189
190
  throw new TypeError('branch must be a valid Git ref');
190
191
  }
191
192
  }
193
+ function mirrorBase(value) {
194
+ if (value === undefined)
195
+ return undefined;
196
+ const url = URL.canParse(value) ? new URL(value) : undefined;
197
+ if (!url || !['https:', 'http:'].includes(url.protocol) || url.search || url.hash) {
198
+ throw new TypeError('mirror must be an http(s) URL without a query or fragment');
199
+ }
200
+ return url.href.replace(/\/+$/, '');
201
+ }
192
202
  function cacheTtl(value, fallback, name) {
193
203
  const ttl = value ?? fallback;
194
204
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -279,6 +289,8 @@ export function createDocsClient(options = {}) {
279
289
  const apiRepoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
280
290
  const rawRepoUrl = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
281
291
  const encodedBranch = encodeURIComponent(branch);
292
+ const mirror = mirrorBase(options.mirror);
293
+ let mirrorFailed = false;
282
294
  const dirTtlMs = cacheTtl(options.cacheTtlMs?.dir, DEFAULTS.dirTtlMs, 'cacheTtlMs.dir');
283
295
  const fileTtlMs = cacheTtl(options.cacheTtlMs?.file, DEFAULTS.fileTtlMs, 'cacheTtlMs.file');
284
296
  const dirCache = new TtlCache(dirTtlMs, 30);
@@ -321,14 +333,14 @@ export function createDocsClient(options = {}) {
321
333
  requestHeaders.Authorization = `Bearer ${token}`;
322
334
  return requestHeaders;
323
335
  }
324
- async function withResponse(url, timeoutMs, consume) {
336
+ async function withResponse(url, timeoutMs, consume, requestHeaders = headers()) {
325
337
  const ctrl = new AbortController();
326
338
  const timer = setTimeout(() => {
327
339
  ctrl.abort();
328
340
  }, timeoutMs);
329
341
  let response;
330
342
  try {
331
- response = await fetch(url, { signal: ctrl.signal, headers: headers() });
343
+ response = await fetch(url, { signal: ctrl.signal, headers: requestHeaders });
332
344
  return await consume(response);
333
345
  }
334
346
  finally {
@@ -337,6 +349,36 @@ export function createDocsClient(options = {}) {
337
349
  cancelUnusedResponseBody(response);
338
350
  }
339
351
  }
352
+ async function fromMirror(base, path, read) {
353
+ if (mirrorFailed)
354
+ return undefined;
355
+ try {
356
+ return await withResponse(`${base}/${path}`, MIRROR_TIMEOUT_MS, async (response) => {
357
+ if (response.status >= 500)
358
+ mirrorFailed = true;
359
+ return response.ok ? await read(response) : undefined;
360
+ }, {});
361
+ }
362
+ catch {
363
+ mirrorFailed = true;
364
+ return undefined;
365
+ }
366
+ }
367
+ function keepTree(items, generation) {
368
+ if (generation === cacheGeneration) {
369
+ treeCache.set(TREE_KEY, copyItems(items));
370
+ if (store)
371
+ storeWrite(TREE_KEY, JSON.stringify(items));
372
+ }
373
+ return items;
374
+ }
375
+ async function keepFile(path, content, generation) {
376
+ if (generation === cacheGeneration)
377
+ fileCache.set(path, content);
378
+ if (store)
379
+ storeWrite(`blob-${await blobSha(content)}`, content);
380
+ return content;
381
+ }
340
382
  function recoverFailure(cache, key, path, error, copy = (value) => value) {
341
383
  const stale = cache.getStale(key);
342
384
  if (stale !== undefined)
@@ -394,6 +436,14 @@ export function createDocsClient(options = {}) {
394
436
  }
395
437
  async function loadAll(generation) {
396
438
  const key = TREE_KEY;
439
+ if (mirror !== undefined) {
440
+ const mirrored = await fromMirror(mirror, 'index.json', async (response) => {
441
+ const data = parseTreeResponse(await response.json());
442
+ return data.truncated ? undefined : filterTree(data.tree);
443
+ });
444
+ if (mirrored)
445
+ return keepTree(mirrored, generation);
446
+ }
397
447
  const url = `${apiRepoUrl}/git/trees/${encodedBranch}?recursive=1`;
398
448
  try {
399
449
  return await withResponse(url, 20000, async (response) => {
@@ -410,13 +460,7 @@ export function createDocsClient(options = {}) {
410
460
  return copyItems(stale);
411
461
  throw new DocsFetchError('', null, 'GitHub truncated the repository tree (too many files) -- results would be incomplete');
412
462
  }
413
- const items = filterTree(data.tree);
414
- if (generation === cacheGeneration) {
415
- treeCache.set(key, copyItems(items));
416
- if (store)
417
- storeWrite(key, JSON.stringify(items));
418
- }
419
- return items;
463
+ return keepTree(filterTree(data.tree), generation);
420
464
  });
421
465
  }
422
466
  catch (error) {
@@ -452,6 +496,11 @@ export function createDocsClient(options = {}) {
452
496
  fileCache.set(path, stored);
453
497
  return stored;
454
498
  }
499
+ if (mirror !== undefined) {
500
+ const mirrored = await fromMirror(mirror, `raw/${encodePath(path)}`, (response) => response.text());
501
+ if (mirrored !== undefined)
502
+ return keepFile(path, mirrored, generation);
503
+ }
455
504
  const url = `${rawRepoUrl}/${encodedBranch}/${encodePath(path)}`;
456
505
  try {
457
506
  return await withResponse(url, 15000, async (response) => {
@@ -461,12 +510,7 @@ export function createDocsClient(options = {}) {
461
510
  return stale;
462
511
  throw new DocsFetchError(path, response.status, `HTTP ${String(response.status)}`);
463
512
  }
464
- const content = await response.text();
465
- if (generation === cacheGeneration)
466
- fileCache.set(path, content);
467
- if (store)
468
- storeWrite(`blob-${await blobSha(content)}`, content);
469
- return content;
513
+ return keepFile(path, await response.text(), generation);
470
514
  });
471
515
  }
472
516
  catch (error) {
package/dist/types.d.ts CHANGED
@@ -51,6 +51,7 @@ export interface DocsClientOptions {
51
51
  file?: number;
52
52
  };
53
53
  store?: DocsStore;
54
+ mirror?: string;
54
55
  }
55
56
  export interface DocsClient {
56
57
  listDir(path?: string): Promise<DocItem[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/docs",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "GitHub-backed document client for NBTCA",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",