veryfront 0.1.86 → 0.1.88

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 (36) hide show
  1. package/esm/deno.js +1 -1
  2. package/esm/src/cache/distributed-cache-init.d.ts +9 -0
  3. package/esm/src/cache/distributed-cache-init.d.ts.map +1 -1
  4. package/esm/src/cache/distributed-cache-init.js +70 -52
  5. package/esm/src/cache/module-cache.d.ts +6 -2
  6. package/esm/src/cache/module-cache.d.ts.map +1 -1
  7. package/esm/src/cache/module-cache.js +73 -56
  8. package/esm/src/platform/adapters/fs/veryfront/content-metrics.d.ts +1 -1
  9. package/esm/src/platform/adapters/fs/veryfront/content-metrics.d.ts.map +1 -1
  10. package/esm/src/platform/adapters/fs/veryfront/content-metrics.js +7 -1
  11. package/esm/src/platform/adapters/fs/veryfront/file-list-index.d.ts +10 -0
  12. package/esm/src/platform/adapters/fs/veryfront/file-list-index.d.ts.map +1 -1
  13. package/esm/src/platform/adapters/fs/veryfront/file-list-index.js +83 -19
  14. package/esm/src/platform/adapters/fs/veryfront/read-operations-helpers.d.ts +5 -0
  15. package/esm/src/platform/adapters/fs/veryfront/read-operations-helpers.d.ts.map +1 -1
  16. package/esm/src/platform/adapters/fs/veryfront/read-operations-helpers.js +6 -0
  17. package/esm/src/platform/adapters/fs/veryfront/read-operations.d.ts.map +1 -1
  18. package/esm/src/platform/adapters/fs/veryfront/read-operations.js +53 -34
  19. package/esm/src/rendering/page-resolution/page-resolver.d.ts.map +1 -1
  20. package/esm/src/rendering/page-resolution/page-resolver.js +10 -4
  21. package/esm/src/transforms/esm/http-cache-wrapper.d.ts +3 -0
  22. package/esm/src/transforms/esm/http-cache-wrapper.d.ts.map +1 -1
  23. package/esm/src/transforms/esm/http-cache-wrapper.js +21 -7
  24. package/esm/src/utils/version.d.ts +1 -1
  25. package/esm/src/utils/version.js +1 -1
  26. package/package.json +1 -1
  27. package/src/deno.js +1 -1
  28. package/src/src/cache/distributed-cache-init.ts +95 -59
  29. package/src/src/cache/module-cache.ts +95 -57
  30. package/src/src/platform/adapters/fs/veryfront/content-metrics.ts +13 -2
  31. package/src/src/platform/adapters/fs/veryfront/file-list-index.ts +101 -18
  32. package/src/src/platform/adapters/fs/veryfront/read-operations-helpers.ts +10 -0
  33. package/src/src/platform/adapters/fs/veryfront/read-operations.ts +87 -30
  34. package/src/src/rendering/page-resolution/page-resolver.ts +17 -6
  35. package/src/src/transforms/esm/http-cache-wrapper.ts +28 -7
  36. package/src/src/utils/version.ts +1 -1
@@ -17,6 +17,11 @@ const logger = rendererLogger.component("module-cache");
17
17
  let moduleCache: LRUCache<string, string> | null = null;
18
18
  let esmCache: LRUCache<string, string> | null = null;
19
19
 
20
+ export interface ModuleCacheMap extends Map<string, string> {
21
+ getOrInsert(key: string, value: string): string;
22
+ getOrInsertComputed(key: string, callback: (key: string) => string): string;
23
+ }
24
+
20
25
  interface ModuleCacheStats {
21
26
  moduleCache: {
22
27
  size: number;
@@ -89,71 +94,104 @@ export function getEsmCache(): LRUCache<string, string> {
89
94
  return getOrInitPodCache(esmPodCacheOptions);
90
95
  }
91
96
 
92
- export function createModuleCache(): Map<string, string> {
97
+ export function createModuleCache(): ModuleCacheMap {
93
98
  return createMapInterface(getModuleCache());
94
99
  }
95
100
 
96
- export function createEsmCache(): Map<string, string> {
101
+ export function createEsmCache(): ModuleCacheMap {
97
102
  return createMapInterface(getEsmCache());
98
103
  }
99
104
 
100
- function createMapInterface(cache: LRUCache<string, string>): Map<string, string> {
101
- const map: Map<string, string> = {
102
- get(key: string): string | undefined {
103
- return cache.get(key);
104
- },
105
- set(key: string, value: string): Map<string, string> {
106
- cache.set(key, value);
107
- return map;
108
- },
109
- has(key: string): boolean {
110
- return cache.has(key);
111
- },
112
- delete(key: string): boolean {
113
- return cache.delete(key);
114
- },
115
- clear(): void {
116
- cache.clear();
117
- },
118
- get size(): number {
119
- return cache.size;
120
- },
121
- keys(): MapIterator<string> {
122
- return cache.keys() as unknown as MapIterator<string>;
123
- },
124
- values(): MapIterator<string> {
125
- const keysIter = cache.keys();
126
- const cacheRef = cache;
127
- return (function* () {
128
- for (const key of keysIter) {
129
- const value = cacheRef.get(key);
130
- if (value !== undefined) yield value;
131
- }
132
- })() as unknown as MapIterator<string>;
133
- },
134
- entries(): MapIterator<[string, string]> {
135
- const keysIter = cache.keys();
136
- const cacheRef = cache;
137
- return (function* () {
138
- for (const key of keysIter) {
139
- const value = cacheRef.get(key);
140
- if (value !== undefined) yield [key, value] as [string, string];
141
- }
142
- })() as unknown as MapIterator<[string, string]>;
143
- },
144
- forEach(callback: (value: string, key: string, map: Map<string, string>) => void): void {
145
- for (const key of cache.keys()) {
146
- const value = cache.get(key);
147
- if (value !== undefined) callback(value, key, map);
105
+ function createMapInterface(cache: LRUCache<string, string>): ModuleCacheMap {
106
+ return new LRUBackedMap(cache);
107
+ }
108
+
109
+ class LRUBackedMap implements ModuleCacheMap {
110
+ readonly [Symbol.toStringTag] = "Map";
111
+
112
+ constructor(private readonly cache: LRUCache<string, string>) {
113
+ }
114
+
115
+ get(key: string): string | undefined {
116
+ return this.cache.get(key);
117
+ }
118
+
119
+ set(key: string, value: string): this {
120
+ this.cache.set(key, value);
121
+ return this;
122
+ }
123
+
124
+ has(key: string): boolean {
125
+ return this.cache.has(key);
126
+ }
127
+
128
+ delete(key: string): boolean {
129
+ return this.cache.delete(key);
130
+ }
131
+
132
+ clear(): void {
133
+ this.cache.clear();
134
+ }
135
+
136
+ getOrInsert(key: string, value: string): string {
137
+ const existing = this.cache.get(key);
138
+ if (existing !== undefined) return existing;
139
+
140
+ this.cache.set(key, value);
141
+ return value;
142
+ }
143
+
144
+ getOrInsertComputed(key: string, callback: (key: string) => string): string {
145
+ const existing = this.cache.get(key);
146
+ if (existing !== undefined) return existing;
147
+
148
+ const value = callback(key);
149
+ this.cache.set(key, value);
150
+ return value;
151
+ }
152
+
153
+ get size(): number {
154
+ return this.cache.size;
155
+ }
156
+
157
+ keys(): MapIterator<string> {
158
+ return this.cache.keys() as unknown as MapIterator<string>;
159
+ }
160
+
161
+ values(): MapIterator<string> {
162
+ const keysIter = this.cache.keys();
163
+ const cacheRef = this.cache;
164
+ return (function* () {
165
+ for (const key of keysIter) {
166
+ const value = cacheRef.get(key);
167
+ if (value !== undefined) yield value;
148
168
  }
149
- },
150
- [Symbol.iterator](): MapIterator<[string, string]> {
151
- return map.entries();
152
- },
153
- [Symbol.toStringTag]: "Map",
154
- };
169
+ })() as unknown as MapIterator<string>;
170
+ }
155
171
 
156
- return map;
172
+ entries(): MapIterator<[string, string]> {
173
+ const keysIter = this.cache.keys();
174
+ const cacheRef = this.cache;
175
+ return (function* () {
176
+ for (const key of keysIter) {
177
+ const value = cacheRef.get(key);
178
+ if (value !== undefined) yield [key, value] as [string, string];
179
+ }
180
+ })() as unknown as MapIterator<[string, string]>;
181
+ }
182
+
183
+ forEach(
184
+ callback: (value: string, key: string, map: Map<string, string>) => void,
185
+ thisArg?: unknown,
186
+ ): void {
187
+ for (const [key, value] of this.entries()) {
188
+ callback.call(thisArg, value, key, this);
189
+ }
190
+ }
191
+
192
+ [Symbol.iterator](): MapIterator<[string, string]> {
193
+ return this.entries();
194
+ }
157
195
  }
158
196
 
159
197
  export function getModuleCacheStats(): ModuleCacheStats {
@@ -8,7 +8,12 @@ import {
8
8
  const logger = baseLogger.component("content-metrics");
9
9
 
10
10
  type FileType = "page" | "layout" | "component" | "api" | "data" | "config" | "other";
11
- export type MissReason = "cold_start" | "not_in_filelist" | "invalidation" | "no_filelist_cache";
11
+ export type MissReason =
12
+ | "cold_start"
13
+ | "not_in_filelist"
14
+ | "invalidation"
15
+ | "no_filelist_cache"
16
+ | "indexed_without_content";
12
17
 
13
18
  interface PerRequestMetrics {
14
19
  startTime: number;
@@ -52,7 +57,13 @@ function createFreshRequestMetrics(): PerRequestMetrics {
52
57
  networkFetches: 0,
53
58
  networkMs: 0,
54
59
  fetchesByType: { page: 0, layout: 0, component: 0, api: 0, data: 0, config: 0, other: 0 },
55
- missReasons: { cold_start: 0, not_in_filelist: 0, invalidation: 0, no_filelist_cache: 0 },
60
+ missReasons: {
61
+ cold_start: 0,
62
+ not_in_filelist: 0,
63
+ invalidation: 0,
64
+ no_filelist_cache: 0,
65
+ indexed_without_content: 0,
66
+ },
56
67
  isPreviewMode: null,
57
68
  filesAccessed: new Set(),
58
69
  };
@@ -7,6 +7,13 @@ interface FileListCacheEntry {
7
7
  content?: string;
8
8
  }
9
9
 
10
+ export interface FileListMatchResult {
11
+ status: "unavailable" | "missing" | "present_without_content" | "hit";
12
+ fresh: boolean;
13
+ path?: string;
14
+ content?: string;
15
+ }
16
+
10
17
  function hashPreview(content: string): number {
11
18
  return content
12
19
  .slice(0, 100)
@@ -22,8 +29,10 @@ const INDEX_STALENESS_LIMIT_MS = 5 * 60 * 1000; // 5 minutes
22
29
 
23
30
  export class FileListIndex {
24
31
  private index: Map<string, string> | null = null;
32
+ private pathSet: Set<string> | null = null;
25
33
  private indexKey: string | null = null;
26
34
  private indexBuiltAt = 0;
35
+ private indexFresh = false;
27
36
  private readyPromise: Promise<void> | null = null;
28
37
 
29
38
  constructor(
@@ -39,8 +48,10 @@ export class FileListIndex {
39
48
 
40
49
  const indexedWithContent = this.index.size;
41
50
  this.index = null;
51
+ this.pathSet = null;
42
52
  this.indexKey = null;
43
53
  this.indexBuiltAt = 0;
54
+ this.indexFresh = false;
44
55
  logger.debug("Cleared file list index", { indexedWithContent });
45
56
  }
46
57
 
@@ -56,21 +67,39 @@ export class FileListIndex {
56
67
  }
57
68
 
58
69
  async lookup(normalizedPath: string): Promise<string | undefined> {
70
+ const match = await this.match(normalizedPath);
71
+ return match.status === "hit" ? match.content : undefined;
72
+ }
73
+
74
+ async match(normalizedPath: string): Promise<FileListMatchResult> {
59
75
  await this.ensureReady();
60
76
 
61
- const index = await this.getOrBuild();
62
- if (!index) {
77
+ const snapshot = await this.getOrBuild();
78
+ if (!snapshot) {
63
79
  logger.debug("No file list cache available");
64
- return undefined;
80
+ return { status: "unavailable", fresh: false };
65
81
  }
66
82
 
67
- const content = index.get(normalizedPath);
68
- if (!content) {
83
+ if (!snapshot.paths.has(normalizedPath)) {
69
84
  logger.debug("Content not in file list index", {
70
85
  path: normalizedPath,
71
- indexSize: index.size,
86
+ indexSize: snapshot.content.size,
87
+ fresh: snapshot.fresh,
72
88
  });
73
- return undefined;
89
+ return { status: "missing", fresh: snapshot.fresh };
90
+ }
91
+
92
+ const content = snapshot.content.get(normalizedPath);
93
+ if (!content) {
94
+ logger.debug("File list index contains path without inline content", {
95
+ path: normalizedPath,
96
+ fresh: snapshot.fresh,
97
+ });
98
+ return {
99
+ status: "present_without_content",
100
+ fresh: snapshot.fresh,
101
+ path: normalizedPath,
102
+ };
74
103
  }
75
104
 
76
105
  logger.debug("FILE_LIST_CACHE_HIT - serving from file list cache", {
@@ -80,26 +109,60 @@ export class FileListIndex {
80
109
  contentPreview: previewText(content, 200).replace(/\n/g, "\\n"),
81
110
  });
82
111
 
83
- return content;
112
+ return {
113
+ status: "hit",
114
+ fresh: snapshot.fresh,
115
+ path: normalizedPath,
116
+ content,
117
+ };
84
118
  }
85
119
 
86
120
  async findFirstWithContent(
87
121
  normalizedPaths: string[],
88
122
  ): Promise<{ path: string; content: string } | undefined> {
123
+ const match = await this.findFirstMatch(normalizedPaths);
124
+ if (match.status !== "hit" || !match.path || !match.content) return undefined;
125
+ return { path: match.path, content: match.content };
126
+ }
127
+
128
+ async findFirstMatch(
129
+ normalizedPaths: string[],
130
+ ): Promise<FileListMatchResult> {
89
131
  await this.ensureReady();
90
132
 
91
- const index = await this.getOrBuild();
92
- if (!index) return undefined;
133
+ const snapshot = await this.getOrBuild();
134
+ if (!snapshot) return { status: "unavailable", fresh: false };
93
135
 
94
136
  for (const path of normalizedPaths) {
95
- const content = index.get(path);
96
- if (content) return { path, content };
137
+ if (!snapshot.paths.has(path)) continue;
138
+
139
+ const content = snapshot.content.get(path);
140
+ if (content) {
141
+ return {
142
+ status: "hit",
143
+ fresh: snapshot.fresh,
144
+ path,
145
+ content,
146
+ };
147
+ }
148
+
149
+ return {
150
+ status: "present_without_content",
151
+ fresh: snapshot.fresh,
152
+ path,
153
+ };
97
154
  }
98
155
 
99
- return undefined;
156
+ return { status: "missing", fresh: snapshot.fresh };
100
157
  }
101
158
 
102
- private async getOrBuild(): Promise<Map<string, string> | null> {
159
+ private async getOrBuild(): Promise<
160
+ {
161
+ content: Map<string, string>;
162
+ paths: Set<string>;
163
+ fresh: boolean;
164
+ } | null
165
+ > {
103
166
  if (!this.getFileListCache) {
104
167
  logger.debug("getOrBuildFileListIndex: no getFileListCache function");
105
168
  return null;
@@ -118,7 +181,12 @@ export class FileListIndex {
118
181
  indexSize: this.index.size,
119
182
  indexAgeMs: age,
120
183
  });
121
- return this.index;
184
+ this.indexFresh = false;
185
+ return {
186
+ content: this.index,
187
+ paths: this.pathSet ?? new Set<string>(),
188
+ fresh: false,
189
+ };
122
190
  }
123
191
  logger.debug("getOrBuildFileListIndex: in-memory index too stale, discarding", {
124
192
  indexSize: this.index.size,
@@ -126,7 +194,9 @@ export class FileListIndex {
126
194
  staleLimitMs: INDEX_STALENESS_LIMIT_MS,
127
195
  });
128
196
  this.index = null;
197
+ this.pathSet = null;
129
198
  this.indexKey = null;
199
+ this.indexFresh = false;
130
200
  }
131
201
  logger.debug(
132
202
  "[ReadOperations] getOrBuildFileListIndex: getFileListCache returned null/undefined",
@@ -146,19 +216,28 @@ export class FileListIndex {
146
216
  const indexKey = `${fileList.length}:${fileList[0]?.path ?? ""}:${
147
217
  fileList[fileList.length - 1]?.path ?? ""
148
218
  }`;
149
- if (this.index && this.indexKey === indexKey) {
219
+ if (this.index && this.pathSet && this.indexKey === indexKey) {
150
220
  this.indexBuiltAt = Date.now();
151
- return this.index;
221
+ this.indexFresh = true;
222
+ return {
223
+ content: this.index,
224
+ paths: this.pathSet,
225
+ fresh: true,
226
+ };
152
227
  }
153
228
 
154
229
  const index = new Map<string, string>();
230
+ const pathSet = new Set<string>();
155
231
  for (const file of fileList) {
232
+ pathSet.add(file.path);
156
233
  if (file.content) index.set(file.path, file.content);
157
234
  }
158
235
 
159
236
  this.index = index;
237
+ this.pathSet = pathSet;
160
238
  this.indexKey = indexKey;
161
239
  this.indexBuiltAt = Date.now();
240
+ this.indexFresh = true;
162
241
 
163
242
  const sampleFile = fileList.find((f) => /welcome/i.test(f.path));
164
243
  const sampleContent = sampleFile?.content;
@@ -171,6 +250,10 @@ export class FileListIndex {
171
250
  sampleContentPreview: sampleContent?.slice(0, 200)?.replace(/\n/g, "\\n"),
172
251
  });
173
252
 
174
- return index;
253
+ return {
254
+ content: index,
255
+ paths: pathSet,
256
+ fresh: true,
257
+ };
175
258
  }
176
259
  }
@@ -6,6 +6,8 @@ import type { ResolvedContentContext } from "./types.js";
6
6
 
7
7
  export { READ_OPERATION_EXTENSION_PRIORITY };
8
8
 
9
+ export type NotFoundLikeError = Error & { code?: string };
10
+
9
11
  interface ReadContextProviderLike {
10
12
  isProductionMode: () => boolean;
11
13
  isPersistentCacheInvalidated?: (prefix: string) => boolean;
@@ -78,6 +80,10 @@ export function getResolvedCacheKey(
78
80
  return `${cacheKeyPrefix}:${normalizedResolvedPath}`;
79
81
  }
80
82
 
83
+ export function buildExtensionCandidatePaths(basePath: string): string[] {
84
+ return READ_OPERATION_EXTENSION_PRIORITY.map((ext) => `${basePath}${ext}`);
85
+ }
86
+
81
87
  export function splitKnownFileExtension(
82
88
  apiPath: string,
83
89
  ): { originalExtension: string; basePath: string } | null {
@@ -95,3 +101,7 @@ export function isNotFoundLikeError(error: unknown): boolean {
95
101
  const errorMessage = error instanceof Error ? error.message : String(error);
96
102
  return errorMessage.includes("404") || errorMessage.includes("Not Found");
97
103
  }
104
+
105
+ export function createNotFoundLikeError(path: string): NotFoundLikeError {
106
+ return Object.assign(new Error(`404 Not Found: ${path}`), { code: "ENOENT" });
107
+ }
@@ -3,13 +3,15 @@ import { withSpan } from "../../../../observability/tracing/otlp-setup.js";
3
3
  import type { VeryfrontApiClient } from "../../veryfront-api-client/index.js";
4
4
  import { FileCache } from "../cache/file-cache.js";
5
5
  import { logContentMetric, type MissReason } from "./content-metrics.js";
6
- import { FileListIndex } from "./file-list-index.js";
6
+ import { FileListIndex, type FileListMatchResult } from "./file-list-index.js";
7
7
  import { InFlightRequestDeduper } from "./in-flight-dedupe.js";
8
8
  import { getRequestScopedFile, setRequestScopedFile } from "./multi-project-adapter.js";
9
9
  import { PathNormalizer } from "./path-normalizer.js";
10
10
  import {
11
11
  assertProjectSourcePath,
12
+ buildExtensionCandidatePaths,
12
13
  buildReadFetchState,
14
+ createNotFoundLikeError,
13
15
  getResolvedCacheKey,
14
16
  isNotFoundLikeError,
15
17
  READ_OPERATION_EXTENSION_PRIORITY as EXTENSION_PRIORITY,
@@ -184,17 +186,23 @@ export class ReadOperations {
184
186
  skipPersistentCaches: boolean,
185
187
  isPreviewMode: boolean,
186
188
  ctx: ResolvedContentContext | null,
187
- ): Promise<string | null> {
189
+ ): Promise<FileListMatchResult> {
188
190
  // File list cache is enabled for BOTH preview and production modes.
189
191
  // The file list is an in-memory index built from API response at init, updated by WebSocket pokes.
190
192
  // This is safe because:
191
193
  // - File list is refreshed on every WebSocket poke (websocket-manager.ts:483-500)
192
194
  // - Request-scoped cache ensures consistency within a single render
193
195
  // - Persistent cache is only written for production mode (to avoid staleness risk in preview)
194
- if (!skipPersistentCaches) {
195
- const fileListContent = await this.fileListIndex.lookup(normalizedPath);
196
- if (!fileListContent) return null;
196
+ if (skipPersistentCaches) {
197
+ logger.debug("Skipping file list cache due to invalidation", {
198
+ path: normalizedPath,
199
+ cacheKeyPrefix,
200
+ });
201
+ return { status: "unavailable", fresh: false };
202
+ }
197
203
 
204
+ const match = await this.fileListIndex.match(normalizedPath);
205
+ if (match.status === "hit" && match.content) {
198
206
  logContentMetric("FILE_LIST_HIT", {
199
207
  path: normalizedPath,
200
208
  mode: ctx?.sourceType ?? "unknown",
@@ -204,24 +212,12 @@ export class ReadOperations {
204
212
  // Only cache to persistent storage for production mode
205
213
  // Preview mode uses file list cache directly without persisting (fresher, WebSocket-driven)
206
214
  if (isProduction) {
207
- this.cache.set(cacheKey, fileListContent);
215
+ this.cache.set(cacheKey, match.content);
208
216
  }
209
- setRequestScopedFile(cacheKey, fileListContent);
210
- return fileListContent;
217
+ setRequestScopedFile(cacheKey, match.content);
211
218
  }
212
219
 
213
- // Skip only happens during cache invalidation (both preview and production)
214
- logContentMetric("CACHE_MISS", {
215
- path: normalizedPath,
216
- mode: ctx?.sourceType ?? "unknown",
217
- missReason: "invalidation" as MissReason,
218
- isPreviewMode,
219
- });
220
- logger.debug("Skipping file list cache due to invalidation", {
221
- path: normalizedPath,
222
- cacheKeyPrefix,
223
- });
224
- return null;
220
+ return match;
225
221
  }
226
222
 
227
223
  private setupInFlightFetch(
@@ -232,6 +228,7 @@ export class ReadOperations {
232
228
  isProduction: boolean,
233
229
  isPreviewMode: boolean,
234
230
  ctx: ResolvedContentContext | null,
231
+ missReason: MissReason,
235
232
  ): Promise<string> {
236
233
  const cleanupResult = this.inFlightRequests.cleanup();
237
234
  if (cleanupResult) {
@@ -249,11 +246,10 @@ export class ReadOperations {
249
246
  }
250
247
 
251
248
  // Track why we're making a network fetch (for optimization analysis)
252
- const hasFileListCache = !!this.getFileListCache;
253
249
  logContentMetric("CACHE_MISS", {
254
250
  path: normalizedPath,
255
251
  mode: ctx?.sourceType ?? "unknown",
256
- missReason: (hasFileListCache ? "not_in_filelist" : "no_filelist_cache") as MissReason,
252
+ missReason,
257
253
  isPreviewMode,
258
254
  });
259
255
 
@@ -377,10 +373,10 @@ export class ReadOperations {
377
373
  isProduction: boolean,
378
374
  ctx: ResolvedContentContext | null,
379
375
  isPreviewMode: boolean,
380
- ): Promise<string | null> {
381
- const candidatePaths = EXTENSION_PRIORITY.map((ext) => `${normalizedPath}${ext}`);
382
- const resolved = await this.fileListIndex.findFirstWithContent(candidatePaths);
383
- if (!resolved) return null;
376
+ ): Promise<FileListMatchResult> {
377
+ const candidatePaths = buildExtensionCandidatePaths(normalizedPath);
378
+ const resolved = await this.fileListIndex.findFirstMatch(candidatePaths);
379
+ if (resolved.status !== "hit" || !resolved.path || !resolved.content) return resolved;
384
380
 
385
381
  const resolvedCacheKey = getResolvedCacheKey(cacheKeyPrefix, resolved.path);
386
382
 
@@ -402,7 +398,7 @@ export class ReadOperations {
402
398
 
403
399
  this.cacheResolvedContent(cacheKey, resolvedCacheKey, resolved.content, isProduction);
404
400
 
405
- return resolved.content;
401
+ return resolved;
406
402
  }
407
403
 
408
404
  private cacheResolvedContent(
@@ -472,7 +468,7 @@ export class ReadOperations {
472
468
  );
473
469
  if (persistentCached) return persistentCached;
474
470
 
475
- const fileListCached = await this.getFileListCacheHit(
471
+ const fileListMatch = await this.getFileListCacheHit(
476
472
  normalizedPath,
477
473
  cacheKeyPrefix,
478
474
  cacheKey,
@@ -481,7 +477,19 @@ export class ReadOperations {
481
477
  isPreviewMode,
482
478
  ctx,
483
479
  );
484
- if (fileListCached) return fileListCached;
480
+ if (fileListMatch.status === "hit" && fileListMatch.content) return fileListMatch.content;
481
+ if (fileListMatch.status === "present_without_content") {
482
+ return this.setupInFlightFetch(
483
+ normalizedPath,
484
+ apiPath,
485
+ cacheKey,
486
+ isPublished,
487
+ isProduction,
488
+ isPreviewMode,
489
+ ctx,
490
+ "indexed_without_content",
491
+ );
492
+ }
485
493
 
486
494
  if (!hasKnownExt) {
487
495
  if (!skipPersistentCaches) {
@@ -493,7 +501,47 @@ export class ReadOperations {
493
501
  ctx,
494
502
  isPreviewMode,
495
503
  );
496
- if (resolvedFromFileList) return resolvedFromFileList;
504
+ if (resolvedFromFileList.status === "hit" && resolvedFromFileList.content) {
505
+ return resolvedFromFileList.content;
506
+ }
507
+
508
+ if (
509
+ resolvedFromFileList.status === "present_without_content" &&
510
+ resolvedFromFileList.path
511
+ ) {
512
+ const resolvedCacheKey = getResolvedCacheKey(cacheKeyPrefix, resolvedFromFileList.path);
513
+ const resolvedApiPath = this.getOriginalApiPath?.(resolvedFromFileList.path) ??
514
+ resolvedFromFileList.path;
515
+ const fetchedResolved = await this.setupInFlightFetch(
516
+ resolvedFromFileList.path,
517
+ resolvedApiPath,
518
+ resolvedCacheKey,
519
+ isPublished,
520
+ isProduction,
521
+ isPreviewMode,
522
+ ctx,
523
+ "indexed_without_content",
524
+ );
525
+
526
+ this.extensionResolutionCache.set(normalizedPath, resolvedFromFileList.path);
527
+ this.cacheResolvedContent(
528
+ cacheKey,
529
+ resolvedCacheKey,
530
+ fetchedResolved,
531
+ isProduction && !skipPersistentCaches,
532
+ );
533
+
534
+ return fetchedResolved;
535
+ }
536
+
537
+ if (
538
+ fileListMatch.status === "missing" &&
539
+ fileListMatch.fresh &&
540
+ resolvedFromFileList.status === "missing" &&
541
+ resolvedFromFileList.fresh
542
+ ) {
543
+ throw createNotFoundLikeError(normalizedPath);
544
+ }
497
545
  }
498
546
 
499
547
  const resolved = await this.tryResolveExtensionlessPath(
@@ -506,6 +554,10 @@ export class ReadOperations {
506
554
  if (resolved) return resolved;
507
555
  }
508
556
 
557
+ if (fileListMatch.status === "missing" && fileListMatch.fresh) {
558
+ throw createNotFoundLikeError(normalizedPath);
559
+ }
560
+
509
561
  return this.setupInFlightFetch(
510
562
  normalizedPath,
511
563
  apiPath,
@@ -514,6 +566,11 @@ export class ReadOperations {
514
566
  isProduction,
515
567
  isPreviewMode,
516
568
  ctx,
569
+ skipPersistentCaches
570
+ ? "invalidation"
571
+ : fileListMatch.status === "missing" && fileListMatch.fresh
572
+ ? "not_in_filelist"
573
+ : "no_filelist_cache",
517
574
  );
518
575
  }
519
576