@thecat69/cache-ctrl 1.1.1 → 1.2.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.
@@ -28,8 +28,16 @@ type BunWatchFunction = (
28
28
  callback: BunWatchCallback,
29
29
  ) => WatcherHandle;
30
30
 
31
- function resolveBunWatch(): Result<BunWatchFunction> {
32
- const watchFn = Reflect.get(Object(Bun), "watch");
31
+ export function resolveBunWatch(): Result<BunWatchFunction> {
32
+ const bunRuntime = Reflect.get(globalThis, "Bun");
33
+ if (typeof bunRuntime !== "object" || bunRuntime === null) {
34
+ return {
35
+ ok: false,
36
+ error: "Bun.watch is not available in this runtime",
37
+ code: ErrorCode.UNKNOWN,
38
+ };
39
+ }
40
+ const watchFn = Reflect.get(bunRuntime, "watch");
33
41
  if (typeof watchFn !== "function") {
34
42
  return {
35
43
  ok: false,
@@ -104,19 +112,38 @@ export async function resolveSourceFilePaths(
104
112
  return [...sourcePaths];
105
113
  }
106
114
 
107
- async function rebuildGraphCache(repoRoot: string, changedPath: string | undefined, verbose: boolean): Promise<void> {
115
+ interface RebuildGraphCacheDependencies {
116
+ resolveSourceFilePaths: typeof resolveSourceFilePaths;
117
+ buildGraph: typeof buildGraph;
118
+ resolveGraphCachePath: typeof resolveGraphCachePath;
119
+ writeCache: typeof writeCache;
120
+ }
121
+
122
+ const defaultRebuildGraphCacheDependencies: RebuildGraphCacheDependencies = {
123
+ resolveSourceFilePaths,
124
+ buildGraph,
125
+ resolveGraphCachePath,
126
+ writeCache,
127
+ };
128
+
129
+ export async function rebuildGraphCache(
130
+ repoRoot: string,
131
+ changedPath: string | undefined,
132
+ verbose: boolean,
133
+ dependencies: RebuildGraphCacheDependencies = defaultRebuildGraphCacheDependencies,
134
+ ): Promise<Result<void>> {
108
135
  try {
109
- const sourceFilePaths = await resolveSourceFilePaths(repoRoot);
110
- const graph = await buildGraph(sourceFilePaths, repoRoot);
111
- const graphCachePath = resolveGraphCachePath(repoRoot);
136
+ const sourceFilePaths = await dependencies.resolveSourceFilePaths(repoRoot);
137
+ const graph = await dependencies.buildGraph(sourceFilePaths, repoRoot);
138
+ const graphCachePath = dependencies.resolveGraphCachePath(repoRoot);
112
139
  const graphPayload: GraphCacheFile = {
113
140
  files: serializeGraphToCache(graph),
114
141
  computed_at: new Date().toISOString(),
115
142
  };
116
- const writeResult = await writeCache(graphCachePath, graphPayload, "replace");
143
+ const writeResult = await dependencies.writeCache(graphCachePath, graphPayload, "replace");
117
144
  if (!writeResult.ok) {
118
145
  process.stderr.write(`[watch] Failed to update graph cache: ${writeResult.error}\n`);
119
- return;
146
+ return writeResult;
120
147
  }
121
148
  if (verbose) {
122
149
  if (changedPath !== undefined) {
@@ -125,9 +152,11 @@ async function rebuildGraphCache(repoRoot: string, changedPath: string | undefin
125
152
  process.stdout.write(`[watch] Initial graph computed: ${graph.size} files\n`);
126
153
  }
127
154
  }
155
+ return { ok: true, value: undefined };
128
156
  } catch (err) {
129
- const message = err instanceof Error ? err.message : String(err);
130
- process.stderr.write(`[watch] Failed to rebuild graph: ${message}\n`);
157
+ const unknownError = toUnknownResult(err);
158
+ process.stderr.write(`[watch] Failed to rebuild graph: ${unknownError.error}\n`);
159
+ return unknownError;
131
160
  }
132
161
  }
133
162
 
@@ -142,25 +171,9 @@ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
142
171
  try {
143
172
  const repoRoot = await findRepoRoot(process.cwd());
144
173
 
145
- const initialSourceFiles = await resolveSourceFilePaths(repoRoot);
146
- const initialGraph = await buildGraph(initialSourceFiles, repoRoot);
147
- const graphCachePath = resolveGraphCachePath(repoRoot);
148
- const initialPayload: GraphCacheFile = {
149
- files: serializeGraphToCache(initialGraph),
150
- computed_at: new Date().toISOString(),
151
- };
152
- const initialWriteResult = await writeCache(graphCachePath, initialPayload, "replace");
153
- if (!initialWriteResult.ok) {
154
- const errorMessage = `[watch] Failed to write initial graph cache: ${initialWriteResult.error}`;
155
- process.stderr.write(`${errorMessage}\n`);
156
- return {
157
- ok: false,
158
- error: errorMessage,
159
- code: ErrorCode.UNKNOWN,
160
- };
161
- }
162
- if (args.verbose) {
163
- process.stdout.write(`[watch] Initial graph computed: ${initialGraph.size} files\n`);
174
+ const initialRebuildResult = await rebuildGraphCache(repoRoot, undefined, args.verbose === true);
175
+ if (!initialRebuildResult.ok) {
176
+ return initialRebuildResult;
164
177
  }
165
178
 
166
179
  let pendingChangedPath: string | undefined;
@@ -180,7 +193,11 @@ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
180
193
  rebuildQueued = false;
181
194
  const changedPath = pendingChangedPath;
182
195
  pendingChangedPath = undefined;
183
- await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
196
+ const rebuildResult = await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
197
+ if (!rebuildResult.ok) {
198
+ // Error already logged in rebuildGraphCache; continue watching for future changes.
199
+ continue;
200
+ }
184
201
  } while (rebuildQueued);
185
202
  } finally {
186
203
  rebuildInProgress = false;
@@ -30,13 +30,6 @@ function getSubmittedTrackedPaths(rawTrackedFiles: unknown): string[] {
30
30
  return rawTrackedFiles.filter(hasStringPath).map((entry) => entry.path);
31
31
  }
32
32
 
33
- function toPlainObjectRecord(rawValue: unknown): Record<string, unknown> {
34
- if (typeof rawValue === "object" && rawValue !== null && !Array.isArray(rawValue)) {
35
- return Object.fromEntries(Object.entries(rawValue));
36
- }
37
- return {};
38
- }
39
-
40
33
  /**
41
34
  * Validates and writes local context cache content with per-path merge semantics.
42
35
  *
@@ -58,7 +51,12 @@ export async function writeLocalCommand(args: WriteArgs): Promise<Result<WriteRe
58
51
  const resolvedTrackedFiles = await resolveTrackedFileStats(submittedPaths.map((path) => ({ path })), repoRoot);
59
52
  const survivingSubmitted = resolvedTrackedFiles.filter((trackedFile) => trackedFile.mtime !== 0);
60
53
 
61
- const submittedFacts = toPlainObjectRecord(contentWithTimestamp["facts"]);
54
+ const submittedFacts =
55
+ typeof contentWithTimestamp["facts"] === "object" &&
56
+ contentWithTimestamp["facts"] !== null &&
57
+ !Array.isArray(contentWithTimestamp["facts"])
58
+ ? (contentWithTimestamp["facts"] as Record<string, unknown>)
59
+ : {};
62
60
  const rawSubmittedFacts = contentWithTimestamp["facts"];
63
61
  if (typeof rawSubmittedFacts === "object" && rawSubmittedFacts !== null && !Array.isArray(rawSubmittedFacts)) {
64
62
  const violatingPaths = Object.keys(submittedFacts).filter((path) => !guardedPaths.has(path));
package/src/index.ts CHANGED
@@ -5,12 +5,13 @@ import { flushCommand } from "./commands/flush.js";
5
5
  import { invalidateCommand } from "./commands/invalidate.js";
6
6
  import { touchCommand } from "./commands/touch.js";
7
7
  import { pruneCommand } from "./commands/prune.js";
8
- import { checkFreshnessCommand } from "./commands/checkFreshness.js";
9
8
  import { checkFilesCommand } from "./commands/checkFiles.js";
10
9
  import { searchCommand } from "./commands/search.js";
11
10
  import { writeLocalCommand } from "./commands/writeLocal.js";
12
11
  import { writeExternalCommand } from "./commands/writeExternal.js";
13
12
  import { installCommand } from "./commands/install.js";
13
+ import { updateCommand } from "./commands/update.js";
14
+ import { uninstallCommand } from "./commands/uninstall.js";
14
15
  import { graphCommand } from "./commands/graph.js";
15
16
  import { mapCommand } from "./commands/map.js";
16
17
  import { watchCommand } from "./commands/watch.js";
@@ -25,12 +26,13 @@ type CommandName =
25
26
  | "invalidate"
26
27
  | "touch"
27
28
  | "prune"
28
- | "check-freshness"
29
29
  | "check-files"
30
30
  | "search"
31
31
  | "write-local"
32
32
  | "write-external"
33
33
  | "install"
34
+ | "update"
35
+ | "uninstall"
34
36
  | "graph"
35
37
  | "map"
36
38
  | "watch"
@@ -129,19 +131,6 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
129
131
  " --delete Actually delete the stale entries (dry-run if omitted)",
130
132
  ].join("\n"),
131
133
  },
132
- "check-freshness": {
133
- usage: "check-freshness <subject-keyword> [--url <url>]",
134
- description: "Send HTTP HEAD requests to verify source freshness",
135
- details: [
136
- " Arguments:",
137
- " <subject-keyword> Keyword identifying the cache entry to check",
138
- "",
139
- " Options:",
140
- " --url <url> Override the URL used for the HEAD request",
141
- "",
142
- " Output: HTTP response metadata and freshness verdict.",
143
- ].join("\n"),
144
- },
145
134
  "check-files": {
146
135
  usage: "check-files",
147
136
  description: "Compare tracked local files against stored mtime/hash",
@@ -202,6 +191,32 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
202
191
  " Output: JSON object describing installed tool/skill paths.",
203
192
  ].join("\n"),
204
193
  },
194
+ update: {
195
+ usage: "update [--config-dir <path>]",
196
+ description: "Update npm package globally and refresh OpenCode integration",
197
+ details: [
198
+ " Arguments:",
199
+ " (none)",
200
+ "",
201
+ " Options:",
202
+ " --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
203
+ "",
204
+ " Output: JSON object with package update status, installed paths, and warnings.",
205
+ ].join("\n"),
206
+ },
207
+ uninstall: {
208
+ usage: "uninstall [--config-dir <path>]",
209
+ description: "Remove OpenCode integration files and uninstall global npm package",
210
+ details: [
211
+ " Arguments:",
212
+ " (none)",
213
+ "",
214
+ " Options:",
215
+ " --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
216
+ "",
217
+ " Output: JSON object with removed paths, npm uninstall status, and warnings.",
218
+ ].join("\n"),
219
+ },
205
220
  graph: {
206
221
  usage: "graph [--max-tokens <number>] [--seed <path>[,<path>...]]",
207
222
  description: "Return a PageRank-ranked dependency graph under a token budget",
@@ -349,7 +364,6 @@ export { usageError };
349
364
  const VALUE_FLAGS = new Set([
350
365
  "data",
351
366
  "agent",
352
- "url",
353
367
  "max-age",
354
368
  "filter",
355
369
  "folder",
@@ -423,7 +437,7 @@ async function main(): Promise<void> {
423
437
 
424
438
  const command = args[0];
425
439
  if (!command) {
426
- usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write-local, write-external, install, graph, map, watch, version");
440
+ usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version");
427
441
  }
428
442
 
429
443
  switch (command) {
@@ -574,22 +588,6 @@ async function main(): Promise<void> {
574
588
  break;
575
589
  }
576
590
 
577
- case "check-freshness": {
578
- const subject = args[1];
579
- if (!subject) {
580
- usageError("Usage: cache-ctrl check-freshness <subject-keyword> [--url <url>]");
581
- }
582
- const url = typeof flags.url === "string" ? flags.url : undefined;
583
- const result = await checkFreshnessCommand({ subject, ...(url !== undefined ? { url } : {}) });
584
- if (result.ok) {
585
- printResult(result, pretty);
586
- } else {
587
- printError(result, pretty);
588
- process.exit(1);
589
- }
590
- break;
591
- }
592
-
593
591
  case "check-files": {
594
592
  const result = await checkFilesCommand();
595
593
  if (result.ok) {
@@ -676,6 +674,9 @@ async function main(): Promise<void> {
676
674
  }
677
675
 
678
676
  case "install": {
677
+ if (flags["config-dir"] === true) {
678
+ usageError("--config-dir requires a value: --config-dir <path>");
679
+ }
679
680
  const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
680
681
  const result = await installCommand({ ...(configDir !== undefined ? { configDir } : {}) });
681
682
  if (result.ok) {
@@ -687,6 +688,36 @@ async function main(): Promise<void> {
687
688
  break;
688
689
  }
689
690
 
691
+ case "update": {
692
+ if (flags["config-dir"] === true) {
693
+ usageError("--config-dir requires a value: --config-dir <path>");
694
+ }
695
+ const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
696
+ const result = await updateCommand({ ...(configDir !== undefined ? { configDir } : {}) });
697
+ if (result.ok) {
698
+ printResult(result, pretty);
699
+ } else {
700
+ printError(result, pretty);
701
+ process.exit(1);
702
+ }
703
+ break;
704
+ }
705
+
706
+ case "uninstall": {
707
+ if (flags["config-dir"] === true) {
708
+ usageError("--config-dir requires a value: --config-dir <path>");
709
+ }
710
+ const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
711
+ const result = await uninstallCommand({ ...(configDir !== undefined ? { configDir } : {}) });
712
+ if (result.ok) {
713
+ printResult(result, pretty);
714
+ } else {
715
+ printError(result, pretty);
716
+ process.exit(1);
717
+ }
718
+ break;
719
+ }
720
+
690
721
  case "graph": {
691
722
  if (flags["max-tokens"] === true) {
692
723
  usageError("--max-tokens requires a numeric value");
@@ -770,7 +801,7 @@ async function main(): Promise<void> {
770
801
  }
771
802
 
772
803
  default:
773
- usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write-local, write-external, install, graph, map, watch, version`);
804
+ usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version`);
774
805
  }
775
806
  }
776
807
 
@@ -21,17 +21,6 @@ const SourceSchema = z.object({
21
21
  version: z.string().optional(),
22
22
  });
23
23
 
24
- const HeaderMetaSchema = z.object({
25
- etag: z.string().optional(),
26
- last_modified: z.string().optional(),
27
- checked_at: z.string(),
28
- // "unchecked" = entry written without HTTP check
29
- status: z.enum(["fresh", "stale", "unchecked"]),
30
- });
31
-
32
- /** Stored HTTP validator metadata for one source URL in an external cache entry. */
33
- export type HeaderMeta = z.infer<typeof HeaderMetaSchema>;
34
-
35
24
  /**
36
25
  * Validates external context cache JSON files stored under `.ai/external-context-gatherer_cache/`.
37
26
  */
@@ -40,7 +29,6 @@ export const ExternalCacheFileSchema = z.looseObject({
40
29
  description: z.string(),
41
30
  fetched_at: z.string(),
42
31
  sources: z.array(SourceSchema),
43
- header_metadata: z.record(z.string(), HeaderMetaSchema),
44
32
  });
45
33
 
46
34
  /** Validates one tracked file baseline used by local file-change detection. */
@@ -125,29 +125,6 @@ export type PruneResult = {
125
125
  };
126
126
  };
127
127
 
128
- // ── check-freshness ───────────────────────────────────────────────────────────
129
-
130
- /** Arguments accepted by the `check-freshness` command. */
131
- export interface CheckFreshnessArgs {
132
- subject: string;
133
- url?: string;
134
- }
135
-
136
- /** Success payload shape returned by the `check-freshness` command. */
137
- export type CheckFreshnessResult = {
138
- ok: true;
139
- value: {
140
- subject: string;
141
- sources: Array<{
142
- url: string;
143
- status: "fresh" | "stale" | "error";
144
- http_status?: number;
145
- error?: string;
146
- }>;
147
- overall: "fresh" | "stale" | "error";
148
- };
149
- };
150
-
151
128
  // ── check-files ───────────────────────────────────────────────────────────────
152
129
 
153
130
  /** Success payload shape returned by the `check-files` command. */
@@ -288,3 +265,29 @@ export type VersionArgs = Record<string, never>;
288
265
 
289
266
  /** Success payload shape returned by the `version` command. */
290
267
  export type VersionResult = { value: { version: string } };
268
+
269
+ // ── update / uninstall ────────────────────────────────────────────────────────
270
+
271
+ /** Arguments accepted by the `update` command. */
272
+ export interface UpdateArgs {
273
+ configDir?: string;
274
+ }
275
+
276
+ /** Success payload shape returned by the `update` command. */
277
+ export interface UpdateResult {
278
+ packageUpdated: boolean;
279
+ installedPaths: string[];
280
+ warnings: string[];
281
+ }
282
+
283
+ /** Arguments accepted by the `uninstall` command. */
284
+ export interface UninstallArgs {
285
+ configDir?: string;
286
+ }
287
+
288
+ /** Success payload shape returned by the `uninstall` command. */
289
+ export interface UninstallResult {
290
+ removed: string[];
291
+ packageUninstalled: boolean;
292
+ warnings: string[];
293
+ }
@@ -28,9 +28,6 @@ export enum ErrorCode {
28
28
  /** Returned when keyword matching yields multiple top-scoring candidates. */
29
29
  AMBIGUOUS_MATCH = "AMBIGUOUS_MATCH",
30
30
 
31
- /** Returned when a user-specified URL is not present in the matched entry's sources list. */
32
- URL_NOT_FOUND = "URL_NOT_FOUND",
33
-
34
31
  /** Returned for unexpected internal exceptions converted at command boundaries. */
35
32
  UNKNOWN = "UNKNOWN",
36
33
  }
@@ -1,123 +0,0 @@
1
- import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
2
- import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
3
- import { checkFreshness } from "../http/freshnessChecker.js";
4
- import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
5
- import { ExternalCacheFileSchema } from "../types/cache.js";
6
- import { ErrorCode, type Result } from "../types/result.js";
7
- import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
8
- import { getFileStem } from "../utils/fileStem.js";
9
- import { toUnknownResult } from "../utils/errors.js";
10
-
11
- /**
12
- * Performs HTTP freshness checks for the matched external cache entry sources.
13
- *
14
- * @param args - {@link CheckFreshnessArgs} command arguments.
15
- * @returns Promise<Result<CheckFreshnessResult["value"]>>; common failures include
16
- * NO_MATCH, URL_NOT_FOUND, PARSE_ERROR, FILE_READ_ERROR/FILE_WRITE_ERROR, and UNKNOWN.
17
- */
18
- export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
19
- try {
20
- const repoRoot = await findRepoRoot(process.cwd());
21
-
22
- // Find the best-matching external cache entry file path
23
- const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
24
- if (!matchResult.ok) return matchResult;
25
-
26
- const filePath = matchResult.value;
27
-
28
- // Load the matched entry's data
29
- const readResult = await readCache(filePath);
30
- if (!readResult.ok) return readResult;
31
-
32
- const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
33
- if (!parseResult.success) {
34
- return { ok: false, error: `Malformed external cache file: ${filePath}`, code: ErrorCode.PARSE_ERROR };
35
- }
36
-
37
- const cacheEntry = parseResult.data;
38
- const stem = getFileStem(filePath);
39
- const subject = cacheEntry.subject ?? stem;
40
-
41
- // Determine which URLs to check
42
- const sources = cacheEntry.sources ?? [];
43
- const specificSource = args.url ? sources.find((source) => source.url === args.url) : undefined;
44
- if (args.url && !specificSource) {
45
- return {
46
- ok: false,
47
- error: `URL not found in sources for subject '${subject}'`,
48
- code: ErrorCode.URL_NOT_FOUND,
49
- };
50
- }
51
- const urlsToCheck = specificSource ? [specificSource] : sources;
52
-
53
- // Check freshness for each URL
54
- const sourceResults: Array<{
55
- url: string;
56
- status: "fresh" | "stale" | "error";
57
- http_status?: number;
58
- error?: string;
59
- }> = [];
60
-
61
- const headerUpdates: Record<string, HeaderMeta> = {};
62
-
63
- for (const source of urlsToCheck) {
64
- const stored = cacheEntry.header_metadata?.[source.url];
65
- const result = await checkFreshness({
66
- url: source.url,
67
- ...(stored?.etag !== undefined ? { etag: stored.etag } : {}),
68
- ...(stored?.last_modified !== undefined ? { last_modified: stored.last_modified } : {}),
69
- });
70
-
71
- sourceResults.push({
72
- url: result.url,
73
- status: result.status,
74
- ...(result.http_status !== undefined ? { http_status: result.http_status } : {}),
75
- ...(result.error !== undefined ? { error: result.error } : {}),
76
- });
77
-
78
- if (result.status !== "error") {
79
- headerUpdates[source.url] = {
80
- ...(result.etag !== undefined ? { etag: result.etag } : {}),
81
- ...(result.last_modified !== undefined ? { last_modified: result.last_modified } : {}),
82
- checked_at: new Date().toISOString(),
83
- status: result.status,
84
- };
85
- }
86
- }
87
-
88
- // Only write back if at least one URL succeeded
89
- const hasSuccessfulChecks = Object.keys(headerUpdates).length > 0;
90
- if (hasSuccessfulChecks) {
91
- const updated = mergeHeaderMetadata(cacheEntry, headerUpdates);
92
- const writeResult = await writeCache(filePath, { header_metadata: updated.header_metadata });
93
- if (!writeResult.ok) return writeResult;
94
- }
95
-
96
- // Determine overall status: entryIsOld always wins (stale by age), then anyStale, then allError
97
- const allError = sourceResults.every((r) => r.status === "error");
98
- const anyStale = sourceResults.some((r) => r.status === "stale");
99
- const entryIsOld = isExternalStale(cacheEntry);
100
-
101
- let overall: "fresh" | "stale" | "error";
102
- if (entryIsOld) {
103
- overall = "stale";
104
- } else if (anyStale) {
105
- overall = "stale";
106
- } else if (allError && sourceResults.length > 0) {
107
- overall = "error";
108
- } else {
109
- overall = "fresh";
110
- }
111
-
112
- return {
113
- ok: true,
114
- value: {
115
- subject,
116
- sources: sourceResults,
117
- overall,
118
- },
119
- };
120
- } catch (err) {
121
- return toUnknownResult(err);
122
- }
123
- }
@@ -1,138 +0,0 @@
1
- /** Input payload for one HTTP freshness check request. */
2
- export interface FreshnessCheckInput {
3
- url: string;
4
- etag?: string;
5
- last_modified?: string;
6
- }
7
-
8
- /**
9
- * Output payload for one HTTP freshness check request.
10
- *
11
- * @remarks Status semantics: HTTP 304 maps to `fresh`, HTTP 200 maps to `stale`, and
12
- * all other outcomes (network errors, blocked URLs, non-200/304 responses) map to `error`.
13
- */
14
- export interface FreshnessCheckOutput {
15
- url: string;
16
- status: "fresh" | "stale" | "error";
17
- http_status?: number;
18
- etag?: string;
19
- last_modified?: string;
20
- error?: string;
21
- }
22
-
23
- /**
24
- * RFC-1918 / loopback / link-local / ULA / mapped-IPv6 IP pattern.
25
- * Blocks raw IP literals only — does NOT do DNS resolution.
26
- *
27
- * Covers:
28
- * - 127.x loopback IPv4
29
- * - ::1 loopback IPv6 (URL.hostname returns "[::1]")
30
- * - localhost loopback hostname
31
- * - 10.x RFC-1918 class A
32
- * - 169.254.x link-local IPv4
33
- * - 172.16–31.x RFC-1918 class B
34
- * - 192.168.x RFC-1918 class C
35
- * - 0.0.0.0 unspecified IPv4
36
- * - fc00::/7 RFC-4193 unique-local IPv6 (ULA — fc or fd prefix)
37
- * - ::ffff: IPv4-mapped IPv6
38
- */
39
- const PRIVATE_IP_PATTERN =
40
- /^(127\.|localhost$|10\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0$|\[::1\]$|::1$|::ffff:|\[::ffff:|f[cd][0-9a-f]{0,2}:|\[f[cd][0-9a-f]{0,2}:)/i;
41
-
42
- /**
43
- * Validates whether a URL is eligible for outbound freshness checks.
44
- *
45
- * @remarks Security control for SSRF risk reduction. Blocks non-HTTP(S) schemes and host
46
- * patterns that target loopback/private address space or raw IP-style local endpoints
47
- * (for example localhost, RFC1918 IPv4 ranges, loopback/link-local, and mapped/ULA IPv6).
48
- */
49
- export function isAllowedUrl(url: string): { allowed: boolean; reason?: string } {
50
- try {
51
- const parsed = new URL(url);
52
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
53
- return { allowed: false, reason: `Disallowed URL scheme — only http and https are permitted: ${url}` };
54
- }
55
- if (PRIVATE_IP_PATTERN.test(parsed.hostname)) {
56
- return { allowed: false, reason: `Requests to private/loopback addresses are not permitted: ${url}` };
57
- }
58
- return { allowed: true };
59
- } catch {
60
- return { allowed: false, reason: `Invalid URL: ${url}` };
61
- }
62
- }
63
-
64
- /**
65
- * Performs a conditional HTTP HEAD freshness check.
66
- *
67
- * @param input - URL plus optional stored validators (`etag`, `last_modified`).
68
- * @returns Freshness verdict and response metadata for one URL.
69
- * @remarks Uses a 10-second abort timeout, sends conditional headers when available,
70
- * maps 304→fresh and 200→stale, and reports all other outcomes as `error`.
71
- */
72
- export async function checkFreshness(input: FreshnessCheckInput): Promise<FreshnessCheckOutput> {
73
- const allowCheck = isAllowedUrl(input.url);
74
- if (!allowCheck.allowed) {
75
- const reason = allowCheck.reason ?? `Freshness check blocked for URL: ${input.url}`;
76
- return {
77
- url: input.url,
78
- status: "error",
79
- error: reason,
80
- };
81
- }
82
-
83
- const controller = new AbortController();
84
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
85
-
86
- try {
87
- const headers: Record<string, string> = {};
88
- if (input.etag) {
89
- headers["If-None-Match"] = input.etag;
90
- }
91
- if (input.last_modified) {
92
- headers["If-Modified-Since"] = input.last_modified;
93
- }
94
-
95
- const response = await fetch(input.url, {
96
- method: "HEAD",
97
- headers,
98
- signal: controller.signal,
99
- });
100
-
101
- if (response.status === 304) {
102
- return {
103
- url: input.url,
104
- status: "fresh",
105
- http_status: 304,
106
- };
107
- }
108
-
109
- if (response.status === 200) {
110
- const etag = response.headers.get("etag") ?? undefined;
111
- const lastModified = response.headers.get("last-modified") ?? undefined;
112
- return {
113
- url: input.url,
114
- status: "stale",
115
- http_status: 200,
116
- ...(etag !== undefined ? { etag } : {}),
117
- ...(lastModified !== undefined ? { last_modified: lastModified } : {}),
118
- };
119
- }
120
-
121
- // 4xx/5xx
122
- return {
123
- url: input.url,
124
- status: "error",
125
- http_status: response.status,
126
- error: `HTTP ${response.status}: ${response.statusText}`,
127
- };
128
- } catch (err) {
129
- const error = err as Error;
130
- return {
131
- url: input.url,
132
- status: "error",
133
- error: error.message,
134
- };
135
- } finally {
136
- clearTimeout(timeoutId);
137
- }
138
- }