diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 059eb234ff..f74ad64226 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -27,7 +27,6 @@ "src/build/renderer/services/css-bundler.test.ts", "src/cache/registry.test.ts", "src/embedding/chunk.test.ts", - "src/embedding/rag-store.test.ts", "src/eval/judges.test.ts", "src/mcp/elicitation.test.ts", "src/mcp/server.test.ts", diff --git a/scripts/typecheck/fixtures/core-frontmatter.ts b/scripts/typecheck/fixtures/core-frontmatter.ts new file mode 100644 index 0000000000..af2208ad36 --- /dev/null +++ b/scripts/typecheck/fixtures/core-frontmatter.ts @@ -0,0 +1,12 @@ +// Consumer fixture for the root package declaration emitted by dnt. +import type { MDXFrontmatter } from "veryfront"; + +const customFrontmatterValue: string | number | boolean | string[] | undefined = true; + +export const frontmatter: MDXFrontmatter = { + title: "Consumer page", + custom: customFrontmatterValue, +}; + +export const legacyCustomValue: string | number | boolean | string[] | undefined = + frontmatter.custom; diff --git a/scripts/typecheck/tsconfig.consumer.json b/scripts/typecheck/tsconfig.consumer.json index 0ee228dded..9366f1d6f6 100644 --- a/scripts/typecheck/tsconfig.consumer.json +++ b/scripts/typecheck/tsconfig.consumer.json @@ -20,6 +20,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "baseUrl": "../..", "paths": { + "veryfront": ["./npm/esm/src/index.d.ts"], "react": ["./storybook/node_modules/@types/react"], "react/jsx-runtime": ["./storybook/node_modules/@types/react/jsx-runtime"], "react/jsx-dev-runtime": ["./storybook/node_modules/@types/react/jsx-dev-runtime"], diff --git a/src/embedding/local-json-store-lock.ts b/src/embedding/local-json-store-lock.ts new file mode 100644 index 0000000000..4e75a26386 --- /dev/null +++ b/src/embedding/local-json-store-lock.ts @@ -0,0 +1,565 @@ +import { + createFileSystem, + type FileSystem, + isAlreadyExistsError, +} from "#veryfront/platform/compat/fs.ts"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; +import { dirname, join } from "#veryfront/platform/compat/path/basic-operations.ts"; +import { resolve } from "#veryfront/platform/compat/path/resolution.ts"; +import { serverLogger } from "#veryfront/utils"; + +const LOCK_ACQUIRE_TIMEOUT_MS = 10_000; +// A live owner renews every five seconds. Twelve missed renewals are required +// before another process may fence it and recover the lock. +const LOCK_STALE_AFTER_MS = 60_000; +const LOCK_HEARTBEAT_INTERVAL_MS = 5_000; +const LOCK_RETRY_INITIAL_MS = 10; +const LOCK_RETRY_MAX_MS = 100; +const LOCK_METADATA_MAX_BYTES = 4_096; +const UUID_V4_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; +const LOCK_TOKEN_PATTERN = new RegExp(`^${UUID_V4_PATTERN}$`, "i"); +const LOCK_OWNER_MARKER_PATTERN = new RegExp( + `^\\.owner\\.(?:recovering|releasing)\\.${UUID_V4_PATTERN}$`, + "i", +); +const LOCK_TEMPORARY_FILE_PATTERN = new RegExp(`^\\.tmp\\.${UUID_V4_PATTERN}$`, "i"); +const OWNER_FILE_NAME = "owner.json"; + +const processLocks = new Map>(); +const logger = serverLogger.component("rag-store-lock"); + +interface LockOwner { + readonly token: string; + readonly createdAtMs: number; +} + +interface LockObservation { + readonly directoryMtimeMs: number | null; + readonly owner: LockOwner | null; + readonly ownerFileName: string | null; + readonly ownerText: string | null; + readonly leaseFileNames: readonly string[]; + readonly leasePresent: boolean; + readonly leaseMtimeMs: number | null; +} + +export interface LocalJsonStoreLease { + readonly temporaryPath: string; + assertOwned(): Promise; +} + +export class LocalJsonStoreLockError extends Error { + override readonly name = "LocalJsonStoreLockError"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +function sleep(durationMs: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, durationMs)); +} + +function withProcessLock(path: string, operation: () => Promise): Promise { + const previous = processLocks.get(path) ?? Promise.resolve(); + const result = previous.then(operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + processLocks.set(path, tail); + void tail.finally(() => { + if (processLocks.get(path) === tail) processLocks.delete(path); + }); + return result; +} + +function parseOwner(text: string): LockOwner | null { + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + if ( + typeof record.token !== "string" || !LOCK_TOKEN_PATTERN.test(record.token) || + typeof record.createdAtMs !== "number" || !Number.isSafeInteger(record.createdAtMs) || + record.createdAtMs < 0 + ) { + return null; + } + return { token: record.token, createdAtMs: record.createdAtMs }; + } catch { + return null; + } +} + +async function readBoundedText(fs: FileSystem, path: string): Promise { + const readWithinLimit = fs.readFileBytesWithinLimit?.bind(fs); + if (!readWithinLimit) { + throw new LocalJsonStoreLockError( + "The native filesystem cannot safely read RAG store lock metadata", + ); + } + try { + const bytes = await readWithinLimit(path, LOCK_METADATA_MAX_BYTES); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + if (isCanonicalNotFoundError(error)) return null; + if (error instanceof RangeError || error instanceof TypeError) return ""; + throw error; + } +} + +async function readObservation( + fs: FileSystem, + lockDirectory: string, +): Promise { + const lstat = fs.lstat?.bind(fs); + if (!lstat) { + throw new LocalJsonStoreLockError( + "The native filesystem cannot safely inspect the RAG store lock", + ); + } + + let directoryInfo; + try { + directoryInfo = await lstat(lockDirectory); + } catch (error) { + if (isCanonicalNotFoundError(error)) return null; + throw error; + } + if (!directoryInfo.isDirectory || directoryInfo.isSymlink) { + throw new LocalJsonStoreLockError("The RAG store lock path is not a real directory"); + } + + let directoryEntryNames: string[] | null = null; + let ownerFileName: string | null = OWNER_FILE_NAME; + let ownerText = await readBoundedText(fs, join(lockDirectory, OWNER_FILE_NAME)); + if (ownerText === null) { + directoryEntryNames = []; + const ownerMarkers: string[] = []; + for await (const entry of fs.readDir(lockDirectory)) { + directoryEntryNames.push(entry.name); + if (LOCK_OWNER_MARKER_PATTERN.test(entry.name)) ownerMarkers.push(entry.name); + } + if (ownerMarkers.length > 1) { + throw new LocalJsonStoreLockError( + "The RAG store lock contains multiple ownership cleanup markers", + ); + } + ownerFileName = ownerMarkers[0] ?? null; + if (ownerFileName !== null) { + ownerText = await readBoundedText(fs, join(lockDirectory, ownerFileName)); + } + } + const owner = ownerText === null ? null : parseOwner(ownerText); + const leaseFileNames: string[] = []; + let leaseMtimeMs: number | null = null; + if (owner !== null) { + const leaseFileName = `${owner.token}.lease`; + try { + const leaseInfo = await lstat(join(lockDirectory, leaseFileName)); + if (!leaseInfo.isFile || leaseInfo.isSymlink) { + throw new LocalJsonStoreLockError("The RAG store lock lease is not a real file"); + } + leaseFileNames.push(leaseFileName); + leaseMtimeMs = leaseInfo.mtime?.getTime() ?? null; + } catch (error) { + if (!isCanonicalNotFoundError(error)) throw error; + } + } else { + // Ownership metadata may be malformed, oversized, or undecodable while a + // live owner is still renewing its token-specific lease. Discover leases + // independently so ambiguous ownership can never make a live lock appear + // ownerless and stale merely because the directory mtime is old. + if (directoryEntryNames === null) { + directoryEntryNames = []; + for await (const entry of fs.readDir(lockDirectory)) { + directoryEntryNames.push(entry.name); + } + } + let newestLeaseMtimeMs: number | null = null; + for (const entryName of directoryEntryNames) { + if (!entryName.endsWith(".lease")) continue; + let leaseInfo; + try { + leaseInfo = await lstat(join(lockDirectory, entryName)); + } catch (error) { + if (isCanonicalNotFoundError(error)) { + throw new LocalJsonStoreLockError( + "RAG store lock lease changed while ownership was being inspected", + { cause: error }, + ); + } + throw error; + } + if (!leaseInfo.isFile || leaseInfo.isSymlink) { + throw new LocalJsonStoreLockError("The RAG store lock lease is not a real file"); + } + leaseFileNames.push(entryName); + const mtimeMs = leaseInfo.mtime?.getTime() ?? null; + if (mtimeMs === null) { + newestLeaseMtimeMs = null; + break; + } + newestLeaseMtimeMs = newestLeaseMtimeMs === null + ? mtimeMs + : Math.max(newestLeaseMtimeMs, mtimeMs); + } + leaseMtimeMs = newestLeaseMtimeMs; + } + + return { + directoryMtimeMs: directoryInfo.mtime?.getTime() ?? null, + owner, + ownerFileName, + ownerText, + leaseFileNames, + leasePresent: leaseFileNames.length > 0, + leaseMtimeMs, + }; +} + +function isStale(observation: LockObservation, nowMs: number): boolean { + if (observation.leasePresent) { + // A present lease with unavailable time metadata cannot be safely fenced. + return observation.leaseMtimeMs !== null && + observation.leaseMtimeMs <= nowMs - LOCK_STALE_AFTER_MS; + } + const lastKnownActivityMs = observation.owner?.createdAtMs ?? observation.directoryMtimeMs; + // A missing lease falls back to immutable owner creation time. Ownerless + // partial acquisitions may use directory time, but missing metadata fails closed. + return lastKnownActivityMs !== null && lastKnownActivityMs <= nowMs - LOCK_STALE_AFTER_MS; +} + +function sameOwner(left: LockObservation, right: LockObservation): boolean { + return left.ownerText === right.ownerText && left.owner?.token === right.owner?.token; +} + +async function removeIfPresent(fs: FileSystem, path: string): Promise { + try { + await fs.remove(path); + } catch (error) { + if (!isCanonicalNotFoundError(error)) throw error; + } +} + +type CleanupPhase = "recovering" | "releasing"; + +async function claimObservedOwner( + fs: FileSystem, + lockDirectory: string, + observation: LockObservation, + phase: CleanupPhase, +): Promise { + if (observation.ownerFileName === null || observation.ownerText === null) return null; + if (observation.ownerFileName !== OWNER_FILE_NAME) return observation.ownerFileName; + + const rename = fs.rename?.bind(fs); + if (!rename) { + throw new LocalJsonStoreLockError( + "The native filesystem cannot claim RAG store lock ownership for cleanup", + ); + } + const markerName = `.owner.${phase}.${crypto.randomUUID()}`; + const markerPath = join(lockDirectory, markerName); + try { + await rename(join(lockDirectory, OWNER_FILE_NAME), markerPath); + } catch (error) { + throw new LocalJsonStoreLockError( + "RAG store lock ownership changed before cleanup could be claimed", + { cause: error }, + ); + } + if (await readBoundedText(fs, markerPath) !== observation.ownerText) { + throw new LocalJsonStoreLockError( + "RAG store lock ownership changed while cleanup was being claimed", + ); + } + return markerName; +} + +/** + * Remove only files belonging to one observed lock generation, then remove the + * directory non-recursively. A replacement generation retains owner.json and + * its token-specific lease, so it cannot be erased in a check-to-delete race. + */ +async function removeObservedLockGeneration( + fs: FileSystem, + lockDirectory: string, + observation: LockObservation, + phase: CleanupPhase, + knownTemporaryNames?: ReadonlySet, +): Promise { + const ownerMarkerName = await claimObservedOwner(fs, lockDirectory, observation, phase); + const leaseNames = new Set(observation.leaseFileNames); + const removableNames: string[] = []; + for await (const entry of fs.readDir(lockDirectory)) { + const isObservedOwner = entry.name === ownerMarkerName; + const isObservedLease = leaseNames.has(entry.name); + const isOwnedTemporary = LOCK_TEMPORARY_FILE_PATTERN.test(entry.name) && + (knownTemporaryNames === undefined || knownTemporaryNames.has(entry.name)); + if (!isObservedOwner && !isObservedLease && !isOwnedTemporary) { + throw new LocalJsonStoreLockError( + "RAG store lock contents changed before cleanup completed", + ); + } + removableNames.push(entry.name); + } + + for (const name of removableNames) { + if (name !== ownerMarkerName) await removeIfPresent(fs, join(lockDirectory, name)); + } + if (ownerMarkerName !== null) { + await removeIfPresent(fs, join(lockDirectory, ownerMarkerName)); + } + try { + await fs.remove(lockDirectory); + } catch (error) { + if (isCanonicalNotFoundError(error)) return; + throw new LocalJsonStoreLockError( + "RAG store lock contents changed before the directory could be removed", + { cause: error }, + ); + } +} + +async function clearInterruptedTransition( + fs: FileSystem, + transitionDirectory: string, + nowMs: number, +): Promise { + const observation = await readObservation(fs, transitionDirectory); + if (observation === null) return true; + if (!isStale(observation, nowMs)) return false; + await removeObservedLockGeneration( + fs, + transitionDirectory, + observation, + "recovering", + ); + return true; +} + +async function restoreUnexpectedRecovery( + fs: FileSystem, + lockDirectory: string, + recoveryDirectory: string, +): Promise { + if (await fs.exists(lockDirectory)) { + throw new LocalJsonStoreLockError( + "RAG store lock ownership changed during stale-lock recovery", + ); + } + const rename = fs.rename?.bind(fs); + if (!rename) { + throw new LocalJsonStoreLockError( + "The native filesystem cannot restore RAG store lock ownership", + ); + } + await rename(recoveryDirectory, lockDirectory); +} + +/** + * Move a stale lock through one deterministic recovery directory before + * deleting it. The post-rename owner check restores a newer generation if + * ownership changed in the observation-to-rename window. Recovery never + * writes inside the observed directory, because doing so would refresh the + * only safe fallback timestamp for an ownerless partial acquisition. + */ +async function tryRecoverStaleLock( + fs: FileSystem, + lockDirectory: string, + recoveryDirectory: string, + observed: LockObservation, +): Promise { + const rename = fs.rename?.bind(fs); + if (!rename) { + throw new LocalJsonStoreLockError( + "The native filesystem cannot recover a stale RAG store lock", + ); + } + + const current = await readObservation(fs, lockDirectory); + if (current === null || !sameOwner(observed, current) || !isStale(current, Date.now())) { + return false; + } + + try { + await rename(lockDirectory, recoveryDirectory); + } catch (error) { + if (isCanonicalNotFoundError(error) || isAlreadyExistsError(error)) return false; + throw error; + } + + const moved = await readObservation(fs, recoveryDirectory); + if (moved === null) return false; + if (!sameOwner(observed, moved) || !isStale(moved, Date.now())) { + await restoreUnexpectedRecovery(fs, lockDirectory, recoveryDirectory); + return false; + } + await removeObservedLockGeneration(fs, recoveryDirectory, moved, "recovering"); + return true; +} + +async function releaseOwnedLock( + fs: FileSystem, + lockDirectory: string, + ownerText: string, + token: string, + temporaryName: string, +): Promise { + const observed = await readObservation(fs, lockDirectory); + if (observed?.owner?.token !== token || observed.ownerText !== ownerText) { + throw new LocalJsonStoreLockError("RAG store lock ownership changed during release"); + } + await removeObservedLockGeneration( + fs, + lockDirectory, + observed, + "releasing", + new Set([temporaryName]), + ); +} + +async function acquireNativeLock( + fs: FileSystem, + storagePath: string, +): Promise<{ + lease: LocalJsonStoreLease; + release(): Promise; +}> { + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + const recoveryDirectory = `${lockDirectory}.recovering`; + const ownerPath = join(lockDirectory, OWNER_FILE_NAME); + const token = crypto.randomUUID(); + const leasePath = join(lockDirectory, `${token}.lease`); + // Publication temps live inside the owned directory. Stale-lock recovery + // atomically moves this directory before deleting it, so a fenced writer + // cannot publish through a check-to-rename scheduling gap. + const temporaryName = `.tmp.${crypto.randomUUID()}`; + const temporaryPath = join(lockDirectory, temporaryName); + const ownerText = `${JSON.stringify({ token, createdAtMs: Date.now() })}\n`; + const startedAtMs = Date.now(); + let retryDelayMs = LOCK_RETRY_INITIAL_MS; + + await fs.mkdir(dirname(storagePath), { recursive: true }); + + while (true) { + const nowMs = Date.now(); + if (await clearInterruptedTransition(fs, recoveryDirectory, nowMs)) { + try { + await fs.mkdir(lockDirectory); + try { + await fs.writeTextFile(ownerPath, ownerText); + await fs.writeTextFile(leasePath, `${nowMs}\n`); + break; + } catch (error) { + const incomplete = await readObservation(fs, lockDirectory).catch(() => null); + if (incomplete?.owner?.token === token && incomplete.ownerText === ownerText) { + await releaseOwnedLock( + fs, + lockDirectory, + ownerText, + token, + temporaryName, + ).catch(() => undefined); + } + throw error; + } + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + const observation = await readObservation(fs, lockDirectory); + if ( + observation !== null && isStale(observation, nowMs) && + await tryRecoverStaleLock(fs, lockDirectory, recoveryDirectory, observation) + ) { + retryDelayMs = LOCK_RETRY_INITIAL_MS; + continue; + } + } + } + + if (nowMs - startedAtMs >= LOCK_ACQUIRE_TIMEOUT_MS) { + throw new LocalJsonStoreLockError( + "Timed out waiting for another RAG store operation to release its lock", + ); + } + await sleep(retryDelayMs); + retryDelayMs = Math.min(retryDelayMs * 2, LOCK_RETRY_MAX_MS); + } + + let released = false; + let heartbeatError: unknown; + let heartbeatTail = Promise.resolve(); + + const assertOwned = async (): Promise => { + if (released) throw new LocalJsonStoreLockError("The RAG store lock was already released"); + if (heartbeatError !== undefined) { + throw new LocalJsonStoreLockError("The RAG store lock heartbeat failed", { + cause: heartbeatError, + }); + } + const observation = await readObservation(fs, lockDirectory); + if (observation?.owner?.token !== token || observation.ownerText !== ownerText) { + throw new LocalJsonStoreLockError("RAG store lock ownership was lost"); + } + }; + + const heartbeat = (): void => { + heartbeatTail = heartbeatTail.then(async () => { + if (released || heartbeatError !== undefined) return; + await assertOwned(); + await fs.writeTextFile(leasePath, `${Date.now()}\n`); + }).catch((error) => { + heartbeatError = error; + }); + }; + const heartbeatId = setInterval(heartbeat, LOCK_HEARTBEAT_INTERVAL_MS); + + return { + lease: { assertOwned, temporaryPath }, + async release(): Promise { + if (released) return; + clearInterval(heartbeatId); + await heartbeatTail; + await assertOwned(); + await releaseOwnedLock(fs, lockDirectory, ownerText, token, temporaryName); + released = true; + }, + }; +} + +/** + * Serialize one local JSON store operation across instances and cooperating + * processes. The in-process queue prevents needless native contention; the + * adjacent lease directory is the actual cross-process ownership boundary. + */ +export async function withLocalJsonStoreLock( + storagePath: string, + operation: (lease: LocalJsonStoreLease) => Promise, +): Promise { + const canonicalPath = resolve(storagePath); + return await withProcessLock(canonicalPath, async () => { + const fs = createFileSystem(); + const acquired = await acquireNativeLock(fs, canonicalPath); + try { + const result = await operation(acquired.lease); + try { + await acquired.release(); + } catch (error) { + logger.error("A completed RAG store operation could not release its lock", { error }); + } + return result; + } catch (operationError) { + try { + await acquired.release(); + } catch (releaseError) { + logger.error("A failed RAG store operation also could not release its lock", { + operationError, + releaseError, + }); + } + throw operationError; + } + }); +} diff --git a/src/embedding/rag-store.test.ts b/src/embedding/rag-store.test.ts index 8bf02145f8..0e90bdc72e 100644 --- a/src/embedding/rag-store.test.ts +++ b/src/embedding/rag-store.test.ts @@ -5,6 +5,7 @@ import { exists, readTextFile, withTempDir } from "#veryfront/testing/deno-compa import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; import { join } from "#veryfront/compat/path"; +import { VeryfrontError } from "#veryfront/errors"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { ragStore } from "./rag-store.ts"; import { clearEmbeddingProviders, registerEmbeddingProvider } from "./resolve.ts"; @@ -68,6 +69,62 @@ describe("ragStore", () => { }); }); + it("creates the storage directory during first ingest", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const store = ragStore({ + model: "local/test-model", + storagePath, + }); + + const id = await store.ingest("Doc", "Hello world"); + + assert(id.length > 0); + assertEquals(await exists(storagePath), true); + assertEquals((await store.listDocuments()).map((document) => document.id), [id]); + }); + }); + + it("treats a missing temp cleanup directory as empty", async () => { + await withTempDir(async (tempDir) => { + const storageDirectory = join(tempDir, "data"); + const storagePath = join(storageDirectory, "index.json"); + const store = ragStore({ + model: "local/test-model", + storagePath, + }); + const readDirDescriptor = Object.getOwnPropertyDescriptor(Deno, "readDir"); + assert(readDirDescriptor !== undefined); + const originalReadDir = Deno.readDir.bind(Deno); + Object.defineProperty(Deno, "readDir", { + ...readDirDescriptor, + value: (path: string | URL) => + String(path) === storageDirectory + ? ({ + [Symbol.asyncIterator]() { + return { + next() { + return Promise.reject( + new Deno.errors.NotFound("missing storage directory"), + ); + }, + }; + }, + } satisfies AsyncIterable) + : originalReadDir(path), + }); + + try { + const id = await store.ingest("Doc", "Hello world"); + + assert(id.length > 0); + assertEquals(await exists(storagePath), true); + } finally { + Object.defineProperty(Deno, "readDir", readDirDescriptor); + } + }); + }); + it("persists ingest with atomic temp+rename workflow", async () => { await withTempDir(async (tempDir) => { const storagePath = join(tempDir, "data", "index.json"); @@ -98,6 +155,491 @@ describe("ragStore", () => { }); }); + it("preserves the live store when its atomic replacement fails", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + await Deno.writeTextFile(storagePath, original); + + const store = ragStore({ + model: "local/test-model", + storagePath, + }); + const renameDescriptor = Object.getOwnPropertyDescriptor(Deno, "rename"); + assert(renameDescriptor !== undefined); + const originalRename = Deno.rename.bind(Deno); + Object.defineProperty(Deno, "rename", { + ...renameDescriptor, + value: (from: string | URL, to: string | URL) => + String(to) === storagePath + ? Promise.reject(new Error("simulated rename failure")) + : originalRename(from, to), + }); + + try { + const error = await assertRejects( + () => store.ingest("Must not persist", "replacement content"), + VeryfrontError, + "could not be completed safely", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + assertEquals(error.message.includes(storagePath), false); + } finally { + Object.defineProperty(Deno, "rename", renameDescriptor); + } + + assertEquals(await readTextFile(storagePath), original); + const entries: string[] = []; + for await (const entry of Deno.readDir(join(tempDir, "data"))) { + entries.push(entry.name); + } + assertEquals(entries, ["index.json"]); + }); + }); + + it("cleans a partially written unique temp file without touching the live store", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + const writeDescriptor = Object.getOwnPropertyDescriptor(Deno, "writeTextFile"); + assert(writeDescriptor !== undefined); + const originalWrite = Deno.writeTextFile.bind(Deno); + Object.defineProperty(Deno, "writeTextFile", { + ...writeDescriptor, + value: async (path: string | URL, data: string, options?: Deno.WriteFileOptions) => { + if (String(path).includes(".tmp.")) { + await originalWrite(path, "partial", options); + throw new Error("simulated partial temp write"); + } + await originalWrite(path, data, options); + }, + }); + + try { + const error = await assertRejects( + () => store.ingest("Must not persist", "replacement content"), + VeryfrontError, + "could not be completed safely", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + assertEquals(error.message.includes(storagePath), false); + } finally { + Object.defineProperty(Deno, "writeTextFile", writeDescriptor); + } + + assertEquals(await readTextFile(storagePath), original); + const entries = await Array.fromAsync(Deno.readDir(join(tempDir, "data"))); + assertEquals(entries.map((entry) => entry.name), ["index.json"]); + }); + }); + + it("cleans an orphaned temp file left by an interrupted process", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + const orphanPath = `${storagePath}.tmp.${crypto.randomUUID()}`; + await Deno.writeTextFile(storagePath, original); + await Deno.writeTextFile(orphanPath, "partial"); + + const store = ragStore({ model: "local/test-model", storagePath }); + assertEquals(await store.listDocuments(), []); + assertEquals(await readTextFile(storagePath), original); + assertEquals(await exists(orphanPath), false); + }); + }); + + it("serializes concurrent store instances targeting the same local index", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const first = ragStore({ model: "local/test-model", storagePath }); + const second = ragStore({ model: "local/test-model", storagePath }); + + await Promise.all([ + first.ingest("First", "first content"), + second.ingest("Second", "second content"), + ]); + + const persisted = JSON.parse(await readTextFile(storagePath)) as { + documents: Array<{ title: string }>; + }; + assertEquals(persisted.documents.map((document) => document.title).sort(), [ + "First", + "Second", + ]); + }); + }); + + it("serializes concurrent local-index writers in separate processes", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const moduleUrl = new URL("./rag-store.ts", import.meta.url).href; + const startAtMs = Date.now() + 250; + const commandFor = (title: string) => + new Deno.Command(Deno.execPath(), { + args: [ + "eval", + `import { ragStore } from ${JSON.stringify(moduleUrl)};` + + `while (Date.now() < ${startAtMs}) await new Promise((resolve) => setTimeout(resolve, 5));` + + `await ragStore({backend:"local-json",model:"local/test-model",storagePath:${ + JSON.stringify(storagePath) + }}).ingest(${JSON.stringify(title)},${JSON.stringify(`${title} content`)});`, + ], + cwd: Deno.cwd(), + stdout: "piped", + stderr: "piped", + }); + + const [first, second] = await Promise.all([ + commandFor("First process").output(), + commandFor("Second process").output(), + ]); + assertEquals(new TextDecoder().decode(first.stderr), ""); + assertEquals(new TextDecoder().decode(second.stderr), ""); + assertEquals(first.success, true); + assertEquals(second.success, true); + + const persisted = JSON.parse(await readTextFile(storagePath)) as { + documents: Array<{ title: string }>; + }; + assertEquals(persisted.documents.map((document) => document.title).sort(), [ + "First process", + "Second process", + ]); + }); + }); + + it("recovers an expired adjacent store lock before reading", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + const token = crypto.randomUUID(); + await Deno.mkdir(lockDirectory, { recursive: true }); + await Deno.writeTextFile( + join(lockDirectory, "owner.json"), + `${JSON.stringify({ token, createdAtMs: 1 })}\n`, + ); + const leasePath = join(lockDirectory, `${token}.lease`); + await Deno.writeTextFile(leasePath, "1\n"); + await Deno.utime(leasePath, new Date(0), new Date(0)); + await Deno.writeTextFile(storagePath, JSON.stringify({ documents: [], chunks: [] })); + + const store = ragStore({ model: "local/test-model", storagePath }); + assertEquals(await store.listDocuments(), []); + assertEquals(await exists(lockDirectory), false); + assertEquals(await exists(`${lockDirectory}.recovering`), false); + }); + }); + + it("recovers a missing lease from immutable owner age without refreshing directory time", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + const token = crypto.randomUUID(); + await Deno.mkdir(lockDirectory, { recursive: true }); + await Deno.writeTextFile( + join(lockDirectory, "owner.json"), + `${JSON.stringify({ token, createdAtMs: 1 })}\n`, + ); + const now = new Date(); + await Deno.utime(lockDirectory, now, now); + await Deno.writeTextFile(storagePath, JSON.stringify({ documents: [], chunks: [] })); + + const store = ragStore({ model: "local/test-model", storagePath }); + assertEquals(await store.listDocuments(), []); + assertEquals(await exists(lockDirectory), false); + assertEquals(await exists(`${lockDirectory}.recovering`), false); + }); + }); + + it("does not fence a fresh lease when owner metadata cannot be trusted", async () => { + const ownerCases = [ + { name: "malformed JSON", bytes: new TextEncoder().encode("{") }, + { name: "invalid UTF-8", bytes: new Uint8Array([0xff]) }, + { name: "oversized metadata", bytes: new Uint8Array(4_097).fill(0x61) }, + ]; + for (const ownerCase of ownerCases) { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, ownerCase.name.replaceAll(" ", "-"), "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + const leasePath = join(lockDirectory, `${crypto.randomUUID()}.lease`); + await Deno.mkdir(lockDirectory, { recursive: true }); + await Deno.writeFile(join(lockDirectory, "owner.json"), ownerCase.bytes); + await Deno.writeTextFile(leasePath, "live\n"); + await Deno.writeTextFile(storagePath, JSON.stringify({ documents: [], chunks: [] })); + await Deno.utime(lockDirectory, new Date(0), new Date(0)); + const freshTime = new Date(); + await Deno.utime(leasePath, freshTime, freshTime); + + let settled = false; + const pendingDocuments = ragStore({ + model: "local/test-model", + storagePath, + }).listDocuments().finally(() => { + settled = true; + }); + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 150)); + const liveLeaseWasPreserved = await exists(lockDirectory) && + await exists(leasePath) && + !await exists(`${lockDirectory}.recovering`) && + !settled; + + // Let the pending contender recover naturally after proving that the + // fresh lease, rather than the old directory mtime, governed staleness. + await Deno.utime(leasePath, new Date(0), new Date(0)); + assertEquals(await pendingDocuments, []); + assertEquals(liveLeaseWasPreserved, true, ownerCase.name); + assertEquals(await exists(lockDirectory), false); + assertEquals(await exists(`${lockDirectory}.recovering`), false); + }); + } + }); + + it("rejects a stale update when the live index changes before publication", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + const external = JSON.stringify({ + documents: [{ + id: "external", + title: "External", + source: "external", + type: "txt", + createdAt: 1, + }], + chunks: [], + }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + const writeDescriptor = Object.getOwnPropertyDescriptor(Deno, "writeTextFile"); + assert(writeDescriptor !== undefined); + const originalWrite = Deno.writeTextFile.bind(Deno); + Object.defineProperty(Deno, "writeTextFile", { + ...writeDescriptor, + value: async (path: string | URL, data: string, options?: Deno.WriteFileOptions) => { + await originalWrite(path, data, options); + if (String(path).includes(".tmp.")) await originalWrite(storagePath, external); + }, + }); + + try { + const error = await assertRejects( + () => store.ingest("Stale", "must not overwrite"), + VeryfrontError, + "changed while an update was in progress", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + } finally { + Object.defineProperty(Deno, "writeTextFile", writeDescriptor); + } + + assertEquals(await readTextFile(storagePath), external); + const entries = await Array.fromAsync(Deno.readDir(join(tempDir, "data"))); + assertEquals(entries.map((entry) => entry.name), ["index.json"]); + }); + }); + + it("compares exact snapshot bytes before publication", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + const originalBytes = new TextEncoder().encode(original); + const externalBytes = new Uint8Array(originalBytes.byteLength + 3); + externalBytes.set([0xef, 0xbb, 0xbf]); + externalBytes.set(originalBytes, 3); + await Deno.writeFile(storagePath, originalBytes); + const store = ragStore({ model: "local/test-model", storagePath }); + const writeDescriptor = Object.getOwnPropertyDescriptor(Deno, "writeTextFile"); + assert(writeDescriptor !== undefined); + const originalWrite = Deno.writeTextFile.bind(Deno); + Object.defineProperty(Deno, "writeTextFile", { + ...writeDescriptor, + value: async (path: string | URL, data: string, options?: Deno.WriteFileOptions) => { + await originalWrite(path, data, options); + if (String(path).includes(".tmp.")) await Deno.writeFile(storagePath, externalBytes); + }, + }); + + try { + const error = await assertRejects( + () => store.ingest("Stale bytes", "must not overwrite"), + VeryfrontError, + "changed while an update was in progress", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + } finally { + Object.defineProperty(Deno, "writeTextFile", writeDescriptor); + } + + assertEquals(await Deno.readFile(storagePath), externalBytes); + }); + }); + + it("fences a writer that loses its adjacent lock before publication", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + const writeDescriptor = Object.getOwnPropertyDescriptor(Deno, "writeTextFile"); + assert(writeDescriptor !== undefined); + const originalWrite = Deno.writeTextFile.bind(Deno); + Object.defineProperty(Deno, "writeTextFile", { + ...writeDescriptor, + value: async (path: string | URL, data: string, options?: Deno.WriteFileOptions) => { + await originalWrite(path, data, options); + if (!String(path).includes(".tmp.")) return; + await Deno.remove(lockDirectory, { recursive: true }); + await Deno.mkdir(lockDirectory); + const replacementToken = crypto.randomUUID(); + await originalWrite( + join(lockDirectory, "owner.json"), + `${JSON.stringify({ token: replacementToken, createdAtMs: Date.now() })}\n`, + ); + await originalWrite(join(lockDirectory, `${replacementToken}.lease`), "replacement\n"); + }, + }); + + try { + const error = await assertRejects( + () => store.ingest("Fenced", "must not overwrite"), + VeryfrontError, + "could not be completed safely", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + } finally { + Object.defineProperty(Deno, "writeTextFile", writeDescriptor); + } + + assertEquals(await readTextFile(storagePath), original); + const entries = await Array.fromAsync(Deno.readDir(join(tempDir, "data"))); + assertEquals( + entries.map((entry) => entry.name).filter((name) => name.includes(".tmp.")), + [], + ); + }); + }); + + it("cannot publish after losing the lease in the final check-to-rename gap", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ documents: [], chunks: [] }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + const renameDescriptor = Object.getOwnPropertyDescriptor(Deno, "rename"); + assert(renameDescriptor !== undefined); + const originalRename = Deno.rename.bind(Deno); + Object.defineProperty(Deno, "rename", { + ...renameDescriptor, + value: async (from: string | URL, to: string | URL) => { + if (String(from).includes(".tmp.") && String(to) === storagePath) { + await Deno.remove(lockDirectory, { recursive: true }); + await Deno.mkdir(lockDirectory); + const replacementToken = crypto.randomUUID(); + await Deno.writeTextFile( + join(lockDirectory, "owner.json"), + `${JSON.stringify({ token: replacementToken, createdAtMs: Date.now() })}\n`, + ); + await Deno.writeTextFile( + join(lockDirectory, `${replacementToken}.lease`), + "replacement\n", + ); + } + await originalRename(from, to); + }, + }); + + try { + const error = await assertRejects( + () => store.ingest("Fenced at rename", "must not overwrite"), + VeryfrontError, + "could not be completed safely", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + assertEquals(error.message.includes(storagePath), false); + } finally { + Object.defineProperty(Deno, "rename", renameDescriptor); + } + + assertEquals(await readTextFile(storagePath), original); + }); + }); + + it("does not delete replacement ownership created in the release gap", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const lockDirectory = `${storagePath}.veryfront-rag.lock`; + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + await Deno.writeTextFile(storagePath, JSON.stringify({ documents: [], chunks: [] })); + const store = ragStore({ model: "local/test-model", storagePath }); + const removeDescriptor = Object.getOwnPropertyDescriptor(Deno, "remove"); + assert(removeDescriptor !== undefined); + const originalRemove = Deno.remove.bind(Deno); + const replacementToken = crypto.randomUUID(); + const replacementOwner = `${ + JSON.stringify({ + token: replacementToken, + createdAtMs: Date.now(), + }) + }\n`; + let replacementInjected = false; + let recursiveLockCleanupAttempted = false; + Object.defineProperty(Deno, "remove", { + ...removeDescriptor, + value: async (path: string | URL, options?: Deno.RemoveOptions) => { + const candidate = String(path); + if (candidate.startsWith(lockDirectory) && options?.recursive === true) { + recursiveLockCleanupAttempted = true; + } + const generationMarkerWindow = candidate.startsWith( + join(lockDirectory, ".owner.releasing."), + ); + if (!replacementInjected && generationMarkerWindow) { + await originalRemove(lockDirectory, { recursive: true }); + await Deno.mkdir(lockDirectory); + await Deno.writeTextFile( + join(lockDirectory, "owner.json"), + replacementOwner, + ); + await Deno.writeTextFile( + join(lockDirectory, `${replacementToken}.lease`), + "replacement\n", + ); + replacementInjected = true; + } + await originalRemove(path, options); + }, + }); + + try { + assertEquals(await store.listDocuments(), []); + } finally { + Object.defineProperty(Deno, "remove", removeDescriptor); + } + + assertEquals(replacementInjected, true); + assertEquals(recursiveLockCleanupAttempted, false); + assertEquals(await readTextFile(join(lockDirectory, "owner.json")), replacementOwner); + }); + }); + it("refreshes an existing local document while preserving its id", async () => { registerTestEmbeddingProvider(); @@ -164,6 +706,204 @@ describe("ragStore", () => { }); }); + it("does not hold the local store lock while embedding the search query", async () => { + let releaseQueryEmbedding!: () => void; + let signalQueryEmbedding!: () => void; + const queryEmbeddingStarted = new Promise((resolve) => { + signalQueryEmbedding = resolve; + }); + const queryEmbeddingReleased = new Promise((resolve) => { + releaseQueryEmbedding = resolve; + }); + registerEmbeddingProvider("slow-query", () => + ({ + specificationVersion: "v2", + provider: "slow-query", + modelId: "test", + maxEmbeddingsPerCall: undefined, + supportsParallelCalls: true, + async doEmbed({ values }: { values: string[] }) { + if (values.length === 1 && values[0] === "needle") { + signalQueryEmbedding(); + await queryEmbeddingReleased; + } + return { + embeddings: values.map((value) => { + const vector = new Array(1536).fill(0); + vector[0] = value.length; + return vector; + }), + usage: { tokens: 0 }, + rawResponse: undefined, + warnings: [], + }; + }, + }) as never); + + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + const store = ragStore({ model: "slow-query/test", storagePath }); + await store.ingest("Doc", "Hello world"); + await store.search("warm"); + + const searchPromise = store.search("needle"); + await queryEmbeddingStarted; + const documents = await Promise.race([ + store.listDocuments(), + new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 50)), + ]); + releaseQueryEmbedding(); + await searchPromise; + + assert(Array.isArray(documents)); + assertEquals(documents.length, 1); + assertEquals(documents[0]?.title, "Doc"); + }); + }); + + it("fails safely when embedding persistence repeatedly loses the store race", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + let embeddingAttempts = 0; + registerEmbeddingProvider("contended-persist", () => + ({ + specificationVersion: "v2", + provider: "contended-persist", + modelId: "test", + maxEmbeddingsPerCall: undefined, + supportsParallelCalls: true, + async doEmbed({ values }: { values: string[] }) { + if (values.length > 1 || values[0] !== "query") { + embeddingAttempts++; + if (embeddingAttempts > 2) { + throw new Error("embedding persistence was retried after exhaustion"); + } + await Deno.writeTextFile( + storagePath, + JSON.stringify({ + documents: [{ + id: "doc", + title: `External ${embeddingAttempts}`, + source: "external", + type: "txt", + createdAt: embeddingAttempts, + }], + chunks: [{ + id: "chunk", + documentId: "doc", + text: "searchable content", + embedding: [], + index: 0, + }], + }), + ); + } + return { + embeddings: values.map((value) => { + const vector = new Array(1536).fill(0); + vector[0] = value.length; + return vector; + }), + usage: { tokens: 0 }, + rawResponse: undefined, + warnings: [], + }; + }, + }) as never); + const store = ragStore({ model: "contended-persist/test", storagePath }); + await store.ingest("Doc", "searchable content"); + + const error = await assertRejects( + () => store.search("query"), + VeryfrontError, + "changed repeatedly while embeddings were persisted", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + assertEquals(embeddingAttempts, 2); + }); + }); + + it("does not report stored-chunk embedding provider failures as storage failures", async () => { + await withTempDir(async (tempDir) => { + registerEmbeddingProvider("failing-doc-embed", () => + ({ + specificationVersion: "v2", + provider: "failing-doc-embed", + modelId: "test", + maxEmbeddingsPerCall: undefined, + supportsParallelCalls: true, + async doEmbed() { + throw new Error("embedding provider timed out"); + }, + }) as never); + + const store = ragStore({ + model: "failing-doc-embed/test", + storagePath: join(tempDir, "data", "index.json"), + }); + await store.ingest("Doc", "searchable content"); + + const error = await assertRejects( + () => store.search("query"), + Error, + "embedding provider timed out", + ); + assert(error instanceof Error); + assertEquals( + error instanceof VeryfrontError && error.slug === "rag-store-unavailable", + false, + ); + assertEquals(error.message.includes("Check storage and retry"), false); + }); + }); + + it("does not report query embedding provider failures as storage failures", async () => { + await withTempDir(async (tempDir) => { + registerEmbeddingProvider("failing-query-embed", () => + ({ + specificationVersion: "v2", + provider: "failing-query-embed", + modelId: "test", + maxEmbeddingsPerCall: undefined, + supportsParallelCalls: true, + async doEmbed({ values }: { values: string[] }) { + if (values.length === 1 && values[0] === "query") { + throw new Error("embedding provider rate limited"); + } + return { + embeddings: values.map((value) => { + const vector = new Array(1536).fill(0); + vector[0] = value.length; + return vector; + }), + usage: { tokens: 0 }, + rawResponse: undefined, + warnings: [], + }; + }, + }) as never); + + const store = ragStore({ + model: "failing-query-embed/test", + storagePath: join(tempDir, "data", "index.json"), + }); + await store.ingest("Doc", "searchable content"); + + const error = await assertRejects( + () => store.search("query"), + Error, + "embedding provider rate limited", + ); + assert(error instanceof Error); + assertEquals( + error instanceof VeryfrontError && error.slug === "rag-store-unavailable", + false, + ); + assertEquals(error.message.includes("Check storage and retry"), false); + }); + }); + it("reuses parsed local store data across searches until storage changes", async () => { registerTestEmbeddingProvider(); @@ -182,7 +922,7 @@ describe("ragStore", () => { let parseCalls = 0; const originalParse = JSON.parse; JSON.parse = ((text, reviver) => { - parseCalls++; + if (text.includes('"documents"') && text.includes('"chunks"')) parseCalls++; return originalParse(text, reviver); }) as typeof JSON.parse; @@ -260,63 +1000,234 @@ describe("ragStore", () => { }); }); - it("resets local store when document entries fail validation", async () => { + it("fails closed without overwriting invalid document entries", async () => { await withTempDir(async (tempDir) => { const storagePath = join(tempDir, "data", "index.json"); await Deno.mkdir(join(tempDir, "data"), { recursive: true }); - await Deno.writeTextFile( + const original = JSON.stringify({ + documents: [{ + id: 123, + title: "Invalid Doc", + source: "upload:invalid.txt", + type: "txt", + createdAt: 1, + }], + chunks: [], + }); + await Deno.writeTextFile(storagePath, original); + + const store = ragStore({ + model: "local/test-model", storagePath, - JSON.stringify({ - documents: [{ - id: 123, - title: "Invalid Doc", - source: "upload:invalid.txt", - type: "txt", - createdAt: 1, - }], - chunks: [], - }), + }); + + await assertRejects( + () => store.listDocuments(), + Error, + "failed validation", ); + await assertRejects( + () => store.ingest("Replacement", "must not overwrite"), + Error, + "failed validation", + ); + assertEquals(await readTextFile(storagePath), original); + }); + }); + + it("fails closed without overwriting invalid chunk entries", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ + documents: [{ + id: "doc-1", + title: "Valid Doc", + source: "upload:valid.txt", + type: "txt", + createdAt: 1, + }], + chunks: [{ + id: "chunk-1", + documentId: "doc-1", + text: "content", + embedding: ["not-a-number"], + index: 0, + }], + }); + await Deno.writeTextFile(storagePath, original); const store = ragStore({ model: "local/test-model", storagePath, }); - assertEquals(await store.listDocuments(), []); + await assertRejects( + () => store.listDocuments(), + Error, + "failed validation", + ); + assertEquals(await readTextFile(storagePath), original); }); }); - it("resets local store when chunk entries fail validation", async () => { + it("fails closed on duplicate document identities before a mutation can discard chunks", async () => { await withTempDir(async (tempDir) => { const storagePath = join(tempDir, "data", "index.json"); await Deno.mkdir(join(tempDir, "data"), { recursive: true }); - await Deno.writeTextFile( - storagePath, - JSON.stringify({ - documents: [{ - id: "doc-1", - title: "Valid Doc", - source: "upload:valid.txt", - type: "txt", - createdAt: 1, - }], - chunks: [{ - id: "chunk-1", - documentId: "doc-1", - text: "content", - embedding: ["not-a-number"], + const original = JSON.stringify({ + documents: [ + { id: "duplicate", title: "First", source: "first", type: "txt", createdAt: 1 }, + { id: "duplicate", title: "Second", source: "second", type: "txt", createdAt: 2 }, + ], + chunks: [ + { + id: "chunk-first", + documentId: "duplicate", + text: "first", + embedding: [], index: 0, - }], - }), + }, + { + id: "chunk-second", + documentId: "duplicate", + text: "second", + embedding: [], + index: 1, + }, + ], + }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + + await assertRejects( + () => store.refreshDocument!("duplicate", "replacement"), + VeryfrontError, + "failed validation", ); + assertEquals(await readTextFile(storagePath), original); + }); + }); + + it("fails closed on chunks that do not belong to a persisted document", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = JSON.stringify({ + documents: [], + chunks: [{ + id: "orphan", + documentId: "missing", + text: "orphaned content", + embedding: [], + index: 0, + }], + }); + await Deno.writeTextFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + + await assertRejects( + () => store.removeDocument("missing"), + VeryfrontError, + "failed validation", + ); + assertEquals(await readTextFile(storagePath), original); + }); + }); + + it("fails closed without overwriting malformed JSON", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = '{"documents":['; + await Deno.writeTextFile(storagePath, original); const store = ragStore({ model: "local/test-model", storagePath, }); - assertEquals(await store.listDocuments(), []); + const error = await assertRejects( + () => store.listDocuments(), + VeryfrontError, + "malformed JSON", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-corrupt"); + assertEquals(error.message.includes(storagePath), false); + assertEquals(error.context, { storagePath }); + await assertRejects( + () => store.removeDocument("anything"), + Error, + "malformed JSON", + ); + assertEquals(await readTextFile(storagePath), original); + }); + }); + + it("fails closed on invalid UTF-8 without replacing persisted bytes", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const original = new Uint8Array([0xff, 0xfe, 0xfd]); + await Deno.writeFile(storagePath, original); + const store = ragStore({ model: "local/test-model", storagePath }); + + const error = await assertRejects( + () => store.listDocuments(), + VeryfrontError, + "not valid UTF-8", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-corrupt"); + assertEquals(error.message.includes(storagePath), false); + assertEquals(await Deno.readFile(storagePath), original); + }); + }); + + it("rejects an oversized index before allocating or parsing its contents", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "data", "index.json"); + await Deno.mkdir(join(tempDir, "data"), { recursive: true }); + const oversizedBytes = 64 * 1024 * 1024 + 1; + await Deno.writeFile(storagePath, new Uint8Array([0x7b])); + await Deno.truncate(storagePath, oversizedBytes); + const store = ragStore({ model: "local/test-model", storagePath }); + + const error = await assertRejects( + () => store.listDocuments(), + VeryfrontError, + "file exceeds size limit", + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-corrupt"); + assertEquals(error.message.includes(storagePath), false); + assertEquals((await Deno.stat(storagePath)).size, oversizedBytes); + }); + }); + + it("rejects a directory store path without deleting matching sibling temps", async () => { + await withTempDir(async (tempDir) => { + const storagePath = join(tempDir, "index.json"); + const siblingTemp = `${storagePath}.tmp.${crypto.randomUUID()}`; + await Deno.mkdir(storagePath); + await Deno.writeTextFile(siblingTemp, "unrelated sibling bytes"); + const store = ragStore({ + model: "local/test-model", + storagePath, + }); + + const error = await assertRejects( + () => store.listDocuments(), + VeryfrontError, + "must be a regular file or be absent", + ); + + assert(error instanceof VeryfrontError); + assertEquals(error.slug, "rag-store-unavailable"); + assertEquals(error.message.includes(storagePath), false); + assertEquals(error.context, { storagePath }); + assertEquals(await readTextFile(siblingTemp), "unrelated sibling bytes"); }); }); @@ -703,7 +1614,9 @@ describe("ragStore", () => { }); assertEquals(embeddingVectors.size, 1); - const listedDocuments = await store.listDocuments() as Array>; + const listedDocuments = await store.listDocuments() as unknown as Array< + Record + >; assertEquals("filePath" in listedDocuments[0]!, false); }, ); diff --git a/src/embedding/rag-store.ts b/src/embedding/rag-store.ts index 1c07386d0c..8c7fe0cd94 100644 --- a/src/embedding/rag-store.ts +++ b/src/embedding/rag-store.ts @@ -1,12 +1,19 @@ import { - isNotFoundError, + createFileSystem, mkdir, readDir, readTextFile, - stat, + remove, writeTextFile, } from "#veryfront/platform/compat/fs.ts"; -import { dirname, extname, join } from "#veryfront/platform/compat/path/basic-operations.ts"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; +import { + basename, + dirname, + extname, + join, +} from "#veryfront/platform/compat/path/basic-operations.ts"; +import { resolve } from "#veryfront/platform/compat/path/resolution.ts"; import { serverLogger } from "#veryfront/utils"; import { isVeryfrontCloudEnabled } from "#veryfront/platform/cloud/resolver.ts"; import { getEnv } from "#veryfront/platform/compat/process.ts"; @@ -26,6 +33,7 @@ import type { RagStoreData, } from "./types.ts"; import { cosineSimilarity } from "#veryfront/runtime/runtime-bridge.ts"; +import { type LocalJsonStoreLease, withLocalJsonStoreLock } from "./local-json-store-lock.ts"; // Legacy data shapes used only for migrating old upload-store JSON files. interface LegacyStoredChunk { @@ -40,32 +48,45 @@ interface LegacyUploadStoreData { uploads: RagDocumentMeta[]; chunks: LegacyStoredChunk[]; } -import { INVALID_ARGUMENT } from "#veryfront/errors"; +import { + INVALID_ARGUMENT, + isVeryfrontError, + RAG_STORE_CORRUPT, + RAG_STORE_UNAVAILABLE, +} from "#veryfront/errors"; type ResolvedRagStoreConfig = RagStoreConfig & { model: string }; /** Default number of top results returned by similarity search. */ const DEFAULT_TOP_K = 5; - -interface StoreFileSignature { - changeTimeMs: number | null; - contentHash: string; - mtimeMs: number | null; - size: number; -} +const MAX_STORED_DOCUMENTS = 100_000; +const MAX_STORED_CHUNKS = 1_000_000; +const MAX_STORED_EMBEDDING_VALUES = 16_384; +const MAX_STORED_BYTES = 64 * 1024 * 1024; +const MAX_ORPHANED_STORE_TEMPS = 1_024; +const MAX_EMBEDDING_PERSIST_ATTEMPTS = 2; +const STORE_TEMP_TOKEN_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; interface StoreDataCache { - signature: StoreFileSignature; + sourceBytes: Uint8Array; data: RagStoreData; } -type StoreFileMetadata = Omit; +interface LoadedStoreData { + data: RagStoreData; + sourceBytes: Uint8Array | null; +} interface StoreFileSnapshot { - signature: StoreFileSignature; + bytes: Uint8Array; text: string; } +class InvalidStoreEncodingError extends Error { + override readonly name = "InvalidStoreEncodingError"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -75,7 +96,8 @@ function isFiniteNumber(value: unknown): value is number { } function isNumberArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every(isFiniteNumber); + return Array.isArray(value) && value.length <= MAX_STORED_EMBEDDING_VALUES && + value.every(isFiniteNumber); } function isNonNegativeInteger(value: unknown): value is number { @@ -103,10 +125,37 @@ function isRagChunk(value: unknown): value is RagChunk { function isRagStoreData(value: unknown): value is RagStoreData { if (!isRecord(value)) return false; - return Array.isArray(value.documents) && - value.documents.every(isRagDocumentMeta) && - Array.isArray(value.chunks) && - value.chunks.every(isRagChunk); + if ( + !Array.isArray(value.documents) || value.documents.length > MAX_STORED_DOCUMENTS || + !Array.isArray(value.chunks) || value.chunks.length > MAX_STORED_CHUNKS + ) { + return false; + } + + const documentIds = new Set(); + for (const document of value.documents) { + if (!isRagDocumentMeta(document) || document.id.length === 0 || documentIds.has(document.id)) { + return false; + } + documentIds.add(document.id); + } + + const chunkIds = new Set(); + const indexesByDocument = new Map>(); + for (const chunk of value.chunks) { + if ( + !isRagChunk(chunk) || chunk.id.length === 0 || chunk.documentId.length === 0 || + chunkIds.has(chunk.id) || !documentIds.has(chunk.documentId) + ) { + return false; + } + chunkIds.add(chunk.id); + const indexes = indexesByDocument.get(chunk.documentId) ?? new Set(); + if (indexes.has(chunk.index)) return false; + indexes.add(chunk.index); + indexesByDocument.set(chunk.documentId, indexes); + } + return true; } function cloneRagStoreData(data: RagStoreData): RagStoreData { @@ -119,23 +168,12 @@ function cloneRagStoreData(data: RagStoreData): RagStoreData { }; } -function hashStoreText(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i++) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index++) { + if (left[index] !== right[index]) return false; } - return hash.toString(16).padStart(8, "0"); -} - -function sameStoreFileSignature( - left: StoreFileSignature, - right: StoreFileSignature, -): boolean { - return left.contentHash === right.contentHash && - left.changeTimeMs === right.changeTimeMs && - left.mtimeMs === right.mtimeMs && - left.size === right.size; + return true; } function isLegacyStoredChunk(value: unknown): value is LegacyStoredChunk { @@ -257,7 +295,8 @@ function resolveRagStoreBackend(config: RagStoreConfig): Exclude = Promise.resolve(); - function withLock(fn: () => Promise): Promise { - const result = mutex.then(fn); - mutex = result.then( - () => {}, - (err) => { - serverLogger.error("[rag-store] Lock operation failed:", err); - }, - ); - return result; + function withLock(fn: (lease: LocalJsonStoreLease) => Promise): Promise { + return withLocalJsonStoreLock(storagePath, async (lease) => { + await validateStoragePath(); + await cleanupOrphanedTempFiles(); + return await fn(lease); + }).catch((error) => { + if (isVeryfrontError(error)) throw error; + throw unavailableStoreError(error); + }); + } + + async function validateStoragePath(): Promise { + const lstat = persistenceFs.lstat?.bind(persistenceFs); + if (!lstat) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The filesystem cannot safely inspect the configured RAG store path.", + context: { storagePath }, + }); + } + try { + const info = await lstat(storagePath); + if (!info.isFile || info.isSymlink) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The configured RAG store path must be a regular file or be absent.", + context: { storagePath }, + }); + } + } catch (error) { + if (isCanonicalNotFoundError(error)) return; + if (isVeryfrontError(error)) throw error; + throw unavailableStoreError(error); + } + } + + async function cleanupOrphanedTempFiles(): Promise { + const storageDirectory = dirname(storagePath); + const storageName = basename(storagePath); + const uniqueTempPrefix = `${storageName}.tmp.`; + let matchingTemps = 0; + try { + for await (const entry of readDir(storageDirectory)) { + const isLegacyTemp = entry.name === `${storageName}.tmp`; + const token = entry.name.startsWith(uniqueTempPrefix) + ? entry.name.slice(uniqueTempPrefix.length) + : null; + if (!isLegacyTemp && (token === null || !STORE_TEMP_TOKEN_PATTERN.test(token))) continue; + matchingTemps++; + if (matchingTemps > MAX_ORPHANED_STORE_TEMPS) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "Too many orphaned RAG store temporary files require cleanup.", + context: { storagePath }, + }); + } + if (!entry.isFile || entry.isSymlink) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "A RAG store temporary path is not a regular file.", + context: { storagePath }, + }); + } + try { + await remove(join(storageDirectory, entry.name)); + } catch (error) { + if (!isCanonicalNotFoundError(error)) throw unavailableStoreError(error); + } + } + } catch (error) { + if (isCanonicalNotFoundError(error)) return; + if (isVeryfrontError(error)) throw error; + throw unavailableStoreError(error); + } } function createEmbedder() { @@ -292,9 +388,9 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { function isLegacyUploadStoreData(value: unknown): value is LegacyUploadStoreData { if (!value || typeof value !== "object") return false; const data = value as { uploads?: unknown; chunks?: unknown }; - return Array.isArray(data.uploads) && + return Array.isArray(data.uploads) && data.uploads.length <= MAX_STORED_DOCUMENTS && data.uploads.every(isRagDocumentMeta) && - Array.isArray(data.chunks) && + Array.isArray(data.chunks) && data.chunks.length <= MAX_STORED_CHUNKS && data.chunks.every(isLegacyStoredChunk); } @@ -311,128 +407,180 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { }; } - async function getStoreFileMetadata(): Promise { + async function readStoreFileSnapshot(): Promise { + const readSnapshot = persistenceFs.readFileSnapshotWithinLimit?.bind(persistenceFs); + if (!readSnapshot) { + throw new Error("The native filesystem cannot safely read the RAG store"); + } + let bytes: Uint8Array; try { - if (typeof Deno !== "undefined") { - const info = await Deno.stat(storagePath); - const changeTime = (info as { ctime?: Date | null }).ctime; - return { - changeTimeMs: changeTime?.getTime() ?? null, - mtimeMs: info.mtime?.getTime() ?? null, - size: info.size, - }; - } - - const info = await stat(storagePath); - let changeTimeMs: number | null = null; - try { - const nodeFs = await import("node:fs/promises"); - const nodeInfo = await nodeFs.stat(storagePath); - changeTimeMs = nodeInfo.ctime.getTime(); - } catch { - // expected: not every runtime exposes a file change time - } + bytes = await readSnapshot(storagePath, dirname(storagePath), MAX_STORED_BYTES); + } catch (error) { + if (isCanonicalNotFoundError(error)) return null; + throw error; + } + try { + return { bytes, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }; + } catch (cause) { + throw new InvalidStoreEncodingError("RAG store is not valid UTF-8", { cause }); + } + } - return { - changeTimeMs, - mtimeMs: info.mtime?.getTime() ?? null, - size: info.size, + function updateStoreDataCache(data: RagStoreData, payloadBytes: Uint8Array): void { + try { + storeDataCache = { + sourceBytes: payloadBytes, + data: cloneRagStoreData(data), }; - } catch (err) { - if (isNotFoundError(err)) return null; - throw err; + } catch (error) { + storeDataCache = null; + serverLogger.warn("[rag-store] Persisted the store but could not refresh its cache", error); } } - async function readStoreFileSnapshot(): Promise { - const metadata = await getStoreFileMetadata(); - if (metadata === null) return null; - - const text = await readTextFile(storagePath); - return { - signature: { - ...metadata, - contentHash: hashStoreText(text), - }, - text, - }; + function corruptStoreError(detail: string, cause?: unknown): Error { + return RAG_STORE_CORRUPT.create({ + detail: `RAG store file is corrupt (${detail}). ` + + "It was preserved as-is and no data was overwritten.", + cause, + context: { storagePath }, + }); } - async function updateStoreDataCache(data: RagStoreData, payload: string): Promise { - const metadata = await getStoreFileMetadata(); - storeDataCache = metadata === null ? null : { - signature: { - ...metadata, - contentHash: hashStoreText(payload), - }, - data: cloneRagStoreData(data), - }; + function unavailableStoreError(cause: unknown): Error { + return RAG_STORE_UNAVAILABLE.create({ + detail: "RAG store operation could not be completed safely. Check storage and retry.", + cause, + context: { storagePath }, + }); } - async function load(): Promise { + async function load(): Promise { + let snapshot: StoreFileSnapshot | null; try { - const snapshot = await readStoreFileSnapshot(); - if (snapshot === null) { - storeDataCache = null; - return { documents: [], chunks: [] }; + snapshot = await readStoreFileSnapshot(); + } catch (err) { + storeDataCache = null; + if (err instanceof RangeError) throw corruptStoreError("file exceeds size limit", err); + if (err instanceof InvalidStoreEncodingError) { + throw corruptStoreError("file is not valid UTF-8", err); } + throw unavailableStoreError(err); + } - if ( - storeDataCache !== null && - sameStoreFileSignature(storeDataCache.signature, snapshot.signature) - ) { - return cloneRagStoreData(storeDataCache.data); - } + if (snapshot === null) { + storeDataCache = null; + return { data: { documents: [], chunks: [] }, sourceBytes: null }; + } - const parsed = JSON.parse(snapshot.text); - if (isLegacyUploadStoreData(parsed)) { - const migrated = migrateLegacyUploadStoreData(parsed); - storeDataCache = { - signature: snapshot.signature, - data: cloneRagStoreData(migrated), - }; - return cloneRagStoreData(migrated); - } - if (!isRagStoreData(parsed)) { - serverLogger.warn("[rag-store] Corrupted store file, resetting", { storagePath }); - storeDataCache = null; - return { documents: [], chunks: [] }; - } - storeDataCache = { signature: snapshot.signature, data: cloneRagStoreData(parsed) }; - return cloneRagStoreData(parsed); - } catch (err) { - // File not found is expected on first run; anything else is worth logging - if (isNotFoundError(err)) { + if ( + storeDataCache !== null && + sameBytes(storeDataCache.sourceBytes, snapshot.bytes) + ) { + return { + data: cloneRagStoreData(storeDataCache.data), + sourceBytes: snapshot.bytes, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(snapshot.text); + } catch (cause) { + storeDataCache = null; + throw corruptStoreError("malformed JSON", cause); + } + + if (isLegacyUploadStoreData(parsed)) { + const migrated = migrateLegacyUploadStoreData(parsed); + if (!isRagStoreData(migrated)) { storeDataCache = null; - return { documents: [], chunks: [] }; + throw corruptStoreError("legacy document or chunk relationships failed validation"); } - serverLogger.warn("[rag-store] Failed to load store, resetting", err); + storeDataCache = { + sourceBytes: snapshot.bytes, + data: cloneRagStoreData(migrated), + }; + return { data: cloneRagStoreData(migrated), sourceBytes: snapshot.bytes }; + } + if (!isRagStoreData(parsed)) { storeDataCache = null; - return { documents: [], chunks: [] }; + throw corruptStoreError("document or chunk entries failed validation"); } + storeDataCache = { + sourceBytes: snapshot.bytes, + data: cloneRagStoreData(parsed), + }; + return { data: cloneRagStoreData(parsed), sourceBytes: snapshot.bytes }; } - async function save(data: RagStoreData): Promise { + async function save( + data: RagStoreData, + expectedSourceBytes: Uint8Array | null, + lease: LocalJsonStoreLease, + ): Promise { + if (!isRagStoreData(data)) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The RAG store update violated persisted-data limits or relationships.", + context: { storagePath }, + }); + } const dir = dirname(storagePath); if (dir && dir !== ".") { await mkdir(dir, { recursive: true }); } const payload = JSON.stringify(data); - // Atomic write: write to temp file then rename to prevent corruption on crash - const tmpPath = storagePath + ".tmp"; - await writeTextFile(tmpPath, payload); + const payloadBytes = new TextEncoder().encode(payload); + if (payloadBytes.byteLength > MAX_STORED_BYTES) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The RAG store update exceeds the persisted byte limit.", + context: { storagePath }, + }); + } + const tmpPath = lease.temporaryPath; try { - if (typeof Deno !== "undefined") { - await Deno.rename(tmpPath, storagePath); - } else { - const fs = await import("node:fs/promises"); - await fs.rename(tmpPath, storagePath); + await writeTextFile(tmpPath, payload); + await lease.assertOwned(); + + let currentSnapshot: StoreFileSnapshot | null; + try { + currentSnapshot = await readStoreFileSnapshot(); + } catch (error) { + throw unavailableStoreError(error); } - } catch (_) { - // expected: rename not available in all environments, fall back to direct write - await writeTextFile(storagePath, payload); + if ( + expectedSourceBytes === null + ? currentSnapshot !== null + : currentSnapshot === null || !sameBytes(expectedSourceBytes, currentSnapshot.bytes) + ) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: + "RAG store file changed while an update was in progress. No data was overwritten.", + context: { storagePath }, + }); + } + + await lease.assertOwned(); + const rename = persistenceFs.rename?.bind(persistenceFs); + if (!rename) { + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The filesystem cannot atomically replace the RAG store file.", + context: { storagePath }, + }); + } + await rename(tmpPath, storagePath); + } catch (error) { + try { + await remove(tmpPath); + } catch (cleanupError) { + if (!isCanonicalNotFoundError(cleanupError)) { + serverLogger.warn("[rag-store] Failed to clean up temporary store file", cleanupError); + } + } + if (isVeryfrontError(error)) throw error; + throw unavailableStoreError(error); } - await updateStoreDataCache(data, payload); + updateStoreDataCache(data, payloadBytes); } async function ensureEmbeddings(data: RagStoreData): Promise { @@ -447,6 +595,41 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { return true; } + function sameStoreSource( + expected: Uint8Array | null, + actual: Uint8Array | null, + ): boolean { + if (expected === null) return actual === null; + return actual !== null && sameBytes(expected, actual); + } + + async function loadSearchDataWithEmbeddings(): Promise { + let loaded = await withLock(async () => await load()); + for (let attempt = 0; attempt < MAX_EMBEDDING_PERSIST_ATTEMPTS; attempt++) { + if (loaded.data.chunks.length === 0) return null; + + const updated = await ensureEmbeddings(loaded.data); + if (!updated) return loaded.data; + + const embeddedData = loaded.data; + const persisted = await withLock(async (lease) => { + const current = await load(); + if (!sameStoreSource(loaded.sourceBytes, current.sourceBytes)) { + return { saved: false as const, loaded: current }; + } + await save(embeddedData, loaded.sourceBytes, lease); + return { saved: true as const, data: embeddedData }; + }); + + if (persisted.saved) return persisted.data; + loaded = persisted.loaded; + } + throw RAG_STORE_UNAVAILABLE.create({ + detail: "The RAG store changed repeatedly while embeddings were persisted.", + context: { storagePath }, + }); + } + async function listContentFiles(dir: string): Promise { const files: string[] = []; try { @@ -470,8 +653,9 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { text: string, meta?: { source?: string; type?: string }, ): Promise { - return withLock(async () => { - const data = await load(); + return withLock(async (lease) => { + const loaded = await load(); + const data = loaded.data; const documentId = crypto.randomUUID(); if (text.length > MAX_TEXT_LENGTH) { @@ -503,7 +687,7 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { data.documents.push(doc); data.chunks.push(...chunkRecords); - await save(data); + await save(data, loaded.sourceBytes, lease); return documentId; }); @@ -514,8 +698,9 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { text: string, meta?: RagRefreshOptions, ): Promise { - return withLock(async () => { - const data = await load(); + return withLock(async (lease) => { + const loaded = await load(); + const data = loaded.data; const document = data.documents.find((doc) => doc.id === id); if (!document) { throw INVALID_ARGUMENT.create({ detail: `RAG document not found: ${id}` }); @@ -545,7 +730,7 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { index, })), ); - await save(data); + await save(data, loaded.sourceBytes, lease); }); }, @@ -554,63 +739,59 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { options?: RagSearchOptions, ): Promise { if (!query.trim()) return []; - return withLock(async () => { - const data = await load(); - if (data.chunks.length === 0) return []; - - const updated = await ensureEmbeddings(data); - if (updated) await save(data); - - const embedder = createEmbedder(); - const queryEmbedding = await embedder.embed(query); - const topK = options?.topK ?? DEFAULT_TOP_K; - const threshold = options?.threshold; - - const docMap = new Map(data.documents.map((d) => [d.id, d])); - - const scored = data.chunks.map((c) => { - const doc = docMap.get(c.documentId); - return { - text: c.text, - score: cosineSimilarity(queryEmbedding, c.embedding), - documentId: c.documentId, - title: doc?.title ?? "Unknown", - source: doc?.source ?? "", - type: doc?.type ?? "", - }; - }); + const data = await loadSearchDataWithEmbeddings(); + if (data === null) return []; + const embedder = createEmbedder(); + const queryEmbedding = await embedder.embed(query); + const topK = options?.topK ?? DEFAULT_TOP_K; + const threshold = options?.threshold; - scored.sort((a, b) => b.score - a.score); + const docMap = new Map(data.documents.map((d) => [d.id, d])); - let results = scored.slice(0, topK); - if (threshold !== undefined) { - results = results.filter((r) => r.score >= threshold); - } - return results; + const scored = data.chunks.map((c) => { + const doc = docMap.get(c.documentId); + return { + text: c.text, + score: cosineSimilarity(queryEmbedding, c.embedding), + documentId: c.documentId, + title: doc?.title ?? "Unknown", + source: doc?.source ?? "", + type: doc?.type ?? "", + }; }); + + scored.sort((a, b) => b.score - a.score); + + let results = scored.slice(0, topK); + if (threshold !== undefined) { + results = results.filter((r) => r.score >= threshold); + } + return results; }, async listDocuments(): Promise { return withLock(async () => { - const data = await load(); + const { data } = await load(); return data.documents; }); }, async removeDocument(id: string): Promise { - return withLock(async () => { - const data = await load(); + return withLock(async (lease) => { + const loaded = await load(); + const data = loaded.data; data.documents = data.documents.filter((d) => d.id !== id); data.chunks = data.chunks.filter((c) => c.documentId !== id); - await save(data); + await save(data, loaded.sourceBytes, lease); }); }, async indexContentDir(): Promise { if (!contentDir) return; - return withLock(async () => { - const data = await load(); + return withLock(async (lease) => { + const loaded = await load(); + const data = loaded.data; const indexedSources = new Set(data.documents.map((d) => d.source)); const files = await listContentFiles(contentDir); @@ -657,7 +838,7 @@ function createLocalJsonRagStore(config: ResolvedRagStoreConfig): RagStore { ); } - await save(data); + await save(data, loaded.sourceBytes, lease); }); }, }; diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index 77121ac802..dd0d7481e0 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -29,9 +29,9 @@ describe("error-registry", () => { assertEquals(slugs.length, uniqueSlugs.size, "Duplicate slugs detected"); }); - it("should have 100 registered errors", () => { + it("should have 102 registered errors", () => { const slugs = getAllSlugs(); - assertEquals(slugs.length, 100); + assertEquals(slugs.length, 102); }); }); @@ -319,7 +319,7 @@ describe("error-registry", () => { RUNTIME: 10, ROUTE: 6, MODULE: 6, - SERVER: 16, + SERVER: 18, BOUNDARY: 7, DEV: 5, DEPLOY: 12, diff --git a/src/errors/error-registry/server.ts b/src/errors/error-registry/server.ts index 527157ca68..ce34dc7ddc 100644 --- a/src/errors/error-registry/server.ts +++ b/src/errors/error-registry/server.ts @@ -133,6 +133,24 @@ export const FALLBACK_EXHAUSTED = defineError({ suggestion: "Check service availability and connectivity", }); +/** Persisted RAG index is malformed or failed structural validation. */ +export const RAG_STORE_CORRUPT = defineError({ + slug: "rag-store-corrupt", + category: "SERVER", + status: 500, + title: "RAG store file is corrupt", + suggestion: "Repair or move the store file aside, then retry; it was not overwritten", +}); + +/** A persisted RAG index operation could not be completed safely. */ +export const RAG_STORE_UNAVAILABLE = defineError({ + slug: "rag-store-unavailable", + category: "SERVER", + status: 500, + title: "RAG store file is unavailable", + suggestion: "Check storage availability, permissions, and concurrent operations, then retry", +}); + /** Registry fragment for SERVER errors (slug → definition). */ export const SERVER_REGISTRY = { "port-in-use": PORT_IN_USE, @@ -151,4 +169,6 @@ export const SERVER_REGISTRY = { "cache-invariant-violation": CACHE_INVARIANT_VIOLATION, "release-not-found": RELEASE_NOT_FOUND, "fallback-exhausted": FALLBACK_EXHAUSTED, + "rag-store-corrupt": RAG_STORE_CORRUPT, + "rag-store-unavailable": RAG_STORE_UNAVAILABLE, } as const; diff --git a/src/errors/index.ts b/src/errors/index.ts index ac34b1c4be..ac18e156e1 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -112,6 +112,8 @@ export { PROJECT_EXECUTION_UNAVAILABLE, PROJECT_SOURCE_EMPTY, PUSH_RECEIPT_MISSING, + RAG_STORE_CORRUPT, + RAG_STORE_UNAVAILABLE, RELEASE_BUILD_TIMEOUT, RELEASE_MISSING_VERSION, RELEASE_NOT_FOUND, diff --git a/src/platform/README.md b/src/platform/README.md index 0a1521a8c1..c26aeaac53 100644 --- a/src/platform/README.md +++ b/src/platform/README.md @@ -144,13 +144,13 @@ interface RuntimeAdapter { `FileSystemAdapter` exposes separate optional capabilities for different read guarantees: -| Capability | Contract | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `readFileBytesBounded(path, byteLimit)` | Returns a prefix of at most `byteLimit` bytes. A full-length result does not prove that the file ended at the limit. | -| `readFileBytesWithinLimit(path, byteLimit)` | Returns the complete file only when it fits. It throws `RangeError` when at least one additional byte exists. | -| `readFileSnapshotWithinLimit(path, containmentRoot, byteLimit)` | Returns one verified regular-file generation beneath the canonical root. Native adapters expose this optional capability only when the runtime supports no-follow opens; it is omitted on Windows. Supported adapters reject terminal symlinks, containment escapes, identity changes, and generation changes. | -| `readFileBytes(path)` plus `maxWholeFileReadBytes` | Publishes a fixed upstream whole-object ceiling. The ceiling has no authority when `readFileBytes` is absent. | -| `createFileBytesExclusive(path, content)` | Creates a new file and refuses to replace an existing path. | +| Capability | Contract | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `readFileBytesBounded(path, byteLimit)` | Returns a prefix of at most `byteLimit` bytes. A full-length result does not prove that the file ended at the limit. | +| `readFileBytesWithinLimit(path, byteLimit)` | Returns the complete file only when it fits. It throws `RangeError` when at least one additional byte exists. | +| `readFileSnapshotWithinLimit(path, containmentRoot, byteLimit)` | Returns one verified regular-file generation beneath the canonical root. POSIX adapters publish this capability only with no-follow opens. The Node adapter also publishes it on Windows, where bigint `BigIntStats` identity and generation fields bind the read to an opened handle and unusable native identities are rejected at runtime. Bun and Deno omit the capability on Windows until they document an equivalent metadata contract. Published implementations reject terminal links, containment escapes, missing or ambiguous native identities, identity changes, and generation changes. | +| `readFileBytes(path)` plus `maxWholeFileReadBytes` | Publishes a fixed upstream whole-object ceiling. The ceiling has no authority when `readFileBytes` is absent. | +| `createFileBytesExclusive(path, content)` | Creates a new file and refuses to replace an existing path. | Consumers capture optional capabilities once as own data-property methods. Security and wrapper boundaries quarantine malformed optional publishers; accessors are not invoked. Captured byte results are copied into fixed `ArrayBuffer` storage before they cross the boundary. diff --git a/src/platform/adapters/file-snapshot-error.ts b/src/platform/adapters/file-snapshot-error.ts index c40c830d56..8126ce3a6d 100644 --- a/src/platform/adapters/file-snapshot-error.ts +++ b/src/platform/adapters/file-snapshot-error.ts @@ -1,4 +1,22 @@ const changedErrors = new WeakSet(); +const rejectedPathErrors = new WeakSet(); + +/** Error raised when a requested snapshot path is not an admissible regular file. */ +export class FileSnapshotPathError extends TypeError { + override readonly name = "FileSnapshotPathError"; + + constructor(message: string) { + super(message); + rejectedPathErrors.add(this); + } +} + +/** Return whether a value is a framework-created snapshot path rejection. */ +export function isFileSnapshotPathError( + value: unknown, +): value is FileSnapshotPathError { + return typeof value === "object" && value !== null && rejectedPathErrors.has(value); +} /** Error raised when a file changes while a stable snapshot is being read. */ export class FileSnapshotChangedError extends Error { diff --git a/src/platform/adapters/index.test.ts b/src/platform/adapters/index.test.ts index c0bee5a679..99380882d2 100644 --- a/src/platform/adapters/index.test.ts +++ b/src/platform/adapters/index.test.ts @@ -30,7 +30,9 @@ describe("adapters/index.ts exports", () => { describe("filesystem snapshot errors", () => { it("exports the stable snapshot error contract", async () => { await assertExport("FileSnapshotChangedError", "function"); + await assertExport("FileSnapshotPathError", "function"); await assertExport("isFileSnapshotChangedError", "function"); + await assertExport("isFileSnapshotPathError", "function"); }); }); diff --git a/src/platform/adapters/index.ts b/src/platform/adapters/index.ts index 992ce5b55d..3716170f5f 100644 --- a/src/platform/adapters/index.ts +++ b/src/platform/adapters/index.ts @@ -30,7 +30,12 @@ export type { WebSocketUpgradeResponse, } from "./base.ts"; export { createWebSocketUpgradeResponse, isWebSocketUpgradeResponse } from "./base.ts"; -export { FileSnapshotChangedError, isFileSnapshotChangedError } from "./file-snapshot-error.ts"; +export { + FileSnapshotChangedError, + FileSnapshotPathError, + isFileSnapshotChangedError, + isFileSnapshotPathError, +} from "./file-snapshot-error.ts"; // Detection & registry export { getAdapter } from "./detect.ts"; diff --git a/src/platform/adapters/mock.ts b/src/platform/adapters/mock.ts index 289acc7281..79cb86f32f 100644 --- a/src/platform/adapters/mock.ts +++ b/src/platform/adapters/mock.ts @@ -4,7 +4,8 @@ import { validateTempDirectoryPrefix } from "#veryfront/platform/compat/temp-dir import { requireBoundedFileReadLimit } from "#veryfront/platform/adapters/bounded-file-read.ts"; import type { FileChangeEvent, FileWatcher, RuntimeAdapter, WatchOptions } from "./base.ts"; import { FileSnapshotChangedError } from "./file-snapshot-error.ts"; -import { isAbsolute, relative, resolve, sep } from "#veryfront/platform/compat/path/index.ts"; +import { resolve } from "#veryfront/platform/compat/path/index.ts"; +import { isPathContainedBy } from "./path-containment.ts"; export interface MockRuntimeAdapter extends RuntimeAdapter { fs: RuntimeAdapter["fs"] & { @@ -42,12 +43,6 @@ function isDescendantPath(candidate: string, path: string): boolean { return normalizedCandidate.startsWith(descendantPrefix(normalizedPath)); } -function isContainedPath(path: string, root: string): boolean { - const relation = relative(resolve(root), resolve(path)); - return relation === "" || - (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation)); -} - function equalBytes(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) return false; for (let index = 0; index < left.byteLength; index++) { @@ -311,7 +306,7 @@ export function createMockAdapter(): MockRuntimeAdapter { byteLimit: number, ) => { const boundedLimit = requireBoundedFileReadLimit(byteLimit); - if (!isContainedPath(path, containmentRoot)) { + if (!isPathContainedBy(resolve(path), resolve(containmentRoot))) { throw new TypeError("Snapshot path must be contained by the requested root"); } const normalizedPath = normalizeMockPath(path); diff --git a/src/platform/adapters/path-containment.ts b/src/platform/adapters/path-containment.ts new file mode 100644 index 0000000000..3b4650e319 --- /dev/null +++ b/src/platform/adapters/path-containment.ts @@ -0,0 +1,14 @@ +import { isAbsolute, relative } from "../compat/path/index.ts"; + +/** + * Test whether an absolute candidate is equal to or beneath an absolute root. + * + * The Veryfront path facade deliberately returns portable `/` separators on + * every host, including Windows. Containment must therefore use the facade's + * contract instead of the native runtime separator. + */ +export function isPathContainedBy(candidate: string, root: string): boolean { + const relation = relative(root, candidate); + return relation === "." || + (relation !== ".." && !relation.startsWith("../") && !isAbsolute(relation)); +} diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.bun.test.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.bun.test.ts index acc211a489..248d40c38a 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.bun.test.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.bun.test.ts @@ -4,7 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { constants } from "node:fs"; import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { BunFileSystemAdapter } from "./filesystem-adapter.ts"; import { getBunRuntime } from "./types.ts"; @@ -56,7 +56,11 @@ describe("BunFileSystemAdapter native integration", () => { Error, ); - if (typeof constants.O_NOFOLLOW !== "number" || constants.O_NOFOLLOW === 0) { + if ( + platform() === "win32" || + typeof constants.O_NOFOLLOW !== "number" || + constants.O_NOFOLLOW === 0 + ) { assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); } else { const empty = join(root, "empty.bin"); diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts index e4370ee68c..1b4391607b 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.test.ts @@ -73,7 +73,7 @@ describe("BunFileSystemAdapter", () => { } }); - it("omits only snapshot authority for absent or zero O_NOFOLLOW", () => { + it("requires O_NOFOLLOW on POSIX and omits unproven Windows snapshot authority", () => { const fake = runtimeFor({ size: 0, exists: () => Promise.resolve(true), @@ -82,13 +82,18 @@ describe("BunFileSystemAdapter", () => { }); const TestableAdapter = BunFileSystemAdapter as unknown as new ( runtime: BunFileSystemRuntime, - options: { noFollow?: number }, + options: { noFollow?: number; platform?: "posix" | "windows" }, ) => BunFileSystemAdapter; for (const noFollow of [undefined, 0]) { - const adapter = new TestableAdapter(fake.runtime, { noFollow }); + const adapter = new TestableAdapter(fake.runtime, { noFollow, platform: "posix" }); assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); } + const windowsAdapter = new TestableAdapter(fake.runtime, { + noFollow: 1, + platform: "windows", + }); + assertEquals(Object.hasOwn(windowsAdapter, "readFileSnapshotWithinLimit"), false); }); it("marks only direct built-in instances as native", () => { diff --git a/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts b/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts index 66e4b99eb9..e32f44366a 100644 --- a/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/deno/filesystem-adapter.test.ts @@ -30,30 +30,35 @@ if (isDeno) { await Deno.symlink(exact, link); const adapter = new DenoFileSystemAdapter(); - assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), true); assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); - assertExists(adapter.readFileSnapshotWithinLimit); assertExists(adapter.createFileBytesExclusive); - assertEquals([...await adapter.readFileSnapshotWithinLimit(empty, root, 1)], []); - assertEquals([...await adapter.readFileSnapshotWithinLimit(exact, root, 3)], [1, 2, 3]); - await assertRejects( - () => adapter.readFileSnapshotWithinLimit!(oversized, root, 3), - RangeError, - ); - for (const limit of [0, Number.MAX_SAFE_INTEGER + 1]) { + if (Deno.build.os === "windows") { + assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); + assertEquals(adapter.readFileSnapshotWithinLimit, undefined); + } else { + assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), true); + assertExists(adapter.readFileSnapshotWithinLimit); + assertEquals([...await adapter.readFileSnapshotWithinLimit(empty, root, 1)], []); + assertEquals([...await adapter.readFileSnapshotWithinLimit(exact, root, 3)], [1, 2, 3]); await assertRejects( - () => adapter.readFileSnapshotWithinLimit!(exact, root, limit), + () => adapter.readFileSnapshotWithinLimit!(oversized, root, 3), RangeError, ); + for (const limit of [0, Number.MAX_SAFE_INTEGER + 1]) { + await assertRejects( + () => adapter.readFileSnapshotWithinLimit!(exact, root, limit), + RangeError, + ); + } + await assertRejects( + () => adapter.readFileSnapshotWithinLimit!(directory, root, 3), + TypeError, + ); + await assertRejects( + () => adapter.readFileSnapshotWithinLimit!(link, root, 3), + TypeError, + ); } - await assertRejects( - () => adapter.readFileSnapshotWithinLimit!(directory, root, 3), - TypeError, - ); - await assertRejects( - () => adapter.readFileSnapshotWithinLimit!(link, root, 3), - TypeError, - ); await adapter.createFileBytesExclusive(created, new Uint8Array([0, 255])); assertEquals([...await Deno.readFile(created)], [0, 255]); await assertRejects( @@ -70,15 +75,17 @@ if (isDeno) { } }); - it("omits only snapshot authority for absent or zero O_NOFOLLOW", () => { + it("requires O_NOFOLLOW on POSIX and omits lossy Windows snapshot authority", () => { const TestableAdapter = DenoFileSystemAdapter as unknown as new ( - options: { noFollow?: number }, + options: { noFollow?: number; platform?: "posix" | "windows" }, ) => DenoFileSystemAdapter; for (const noFollow of [undefined, 0]) { - const adapter = new TestableAdapter({ noFollow }); + const adapter = new TestableAdapter({ noFollow, platform: "posix" }); assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); } + const windowsAdapter = new TestableAdapter({ noFollow: 1, platform: "windows" }); + assertEquals(Object.hasOwn(windowsAdapter, "readFileSnapshotWithinLimit"), false); }); it("omits createNew independently when that primitive is unavailable", () => { diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts index 846013599a..1f0c18a0d8 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts @@ -89,15 +89,19 @@ describe("NodeFileSystemAdapter", () => { } }); - it("omits only snapshot authority for absent or zero O_NOFOLLOW", () => { + it("requires O_NOFOLLOW on POSIX and actual Node provenance on Windows", () => { const TestableAdapter = NodeFileSystemAdapter as unknown as new ( - options: { noFollow?: number }, + options: { noFollow?: number; platform?: "posix" | "windows" }, ) => NodeFileSystemAdapter; for (const noFollow of [undefined, 0]) { - const adapter = new TestableAdapter({ noFollow }); + const adapter = new TestableAdapter({ noFollow, platform: "posix" }); assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); } + const windowsAdapter = new TestableAdapter({ noFollow: 1, platform: "windows" }); + // Tests run under Deno; changing path semantics must not forge Node runtime + // provenance for a Node-compatible filesystem implementation. + assertEquals(Object.hasOwn(windowsAdapter, "readFileSnapshotWithinLimit"), false); }); it("does not log an expected missing path as an access failure", async () => { diff --git a/src/platform/adapters/runtime/shared/native-file-capabilities.test.ts b/src/platform/adapters/runtime/shared/native-file-capabilities.test.ts deleted file mode 100644 index 702c75d36f..0000000000 --- a/src/platform/adapters/runtime/shared/native-file-capabilities.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; - -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { FileSnapshotChangedError } from "../../file-snapshot-error.ts"; -import { - createNodeFileBytesExclusive, - type NativeSnapshotOperations, - readNodeFileSnapshotWithinLimit, - supportsNativeFileSnapshots, -} from "./native-file-capabilities.ts"; - -function snapshotStat(overrides: Partial<{ - dev: bigint; - ino: bigint; - size: bigint; - mtimeNs: bigint; - ctimeNs: bigint; -}> = {}) { - return { - dev: overrides.dev ?? 1n, - ino: overrides.ino ?? 2n, - size: overrides.size ?? 3n, - mtimeNs: overrides.mtimeNs ?? 4n, - ctimeNs: overrides.ctimeNs ?? 5n, - isFile: () => true, - isSymbolicLink: () => false, - }; -} - -function snapshotHandle( - stat: ReturnType, - overrides: Partial<{ - stat(): Promise>; - read( - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ): Promise<{ bytesRead: number }>; - writeFile(content: Uint8Array): Promise; - close(): Promise; - }> = {}, -) { - return { - stat: overrides.stat ?? (() => Promise.resolve(stat)), - read: overrides.read ?? - ((buffer: Uint8Array, offset: number, length: number) => { - buffer.fill(7, offset, offset + length); - return Promise.resolve({ bytesRead: length }); - }), - writeFile: overrides.writeFile ?? (() => Promise.resolve()), - close: overrides.close ?? (() => Promise.resolve()), - }; -} - -function stableOperations( - stat = snapshotStat(), - handle = snapshotHandle(stat), -): NativeSnapshotOperations { - return { - realpath: (path) => Promise.resolve(path), - lstat: () => Promise.resolve(stat), - open: () => Promise.resolve(handle), - }; -} - -describe("native filesystem capabilities", () => { - it("advertises snapshots only where no-follow opens are supported", () => { - assertEquals(supportsNativeFileSnapshots("posix"), true); - assertEquals(supportsNativeFileSnapshots("windows"), false); - }); - - it("reads exact and empty snapshots and rejects oversize, links, directories, and escapes", async () => { - if (Deno.build.os === "windows") return; - const root = await Deno.makeTempDir({ prefix: "vf-native-snapshot-" }); - try { - const empty = `${root}/empty.bin`; - const exact = `${root}/exact.bin`; - const oversized = `${root}/oversized.bin`; - const directory = `${root}/directory`; - const link = `${root}/link.bin`; - await Deno.writeFile(empty, new Uint8Array()); - await Deno.writeFile(exact, new Uint8Array([1, 2, 3])); - await Deno.writeFile(oversized, new Uint8Array([1, 2, 3, 4])); - await Deno.mkdir(directory); - await Deno.symlink(exact, link); - - assertEquals([...await readNodeFileSnapshotWithinLimit(empty, root, 1)], []); - assertEquals( - [...await readNodeFileSnapshotWithinLimit(exact, root, 3)], - [1, 2, 3], - ); - await assertRejects( - () => readNodeFileSnapshotWithinLimit(oversized, root, 3), - RangeError, - ); - await assertRejects( - () => readNodeFileSnapshotWithinLimit(directory, root, 3), - TypeError, - ); - await assertRejects( - () => readNodeFileSnapshotWithinLimit(link, root, 3), - TypeError, - ); - await assertRejects( - () => readNodeFileSnapshotWithinLimit(`${root}/../outside.bin`, root, 3), - TypeError, - ); - for (const limit of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { - await assertRejects( - () => readNodeFileSnapshotWithinLimit(exact, root, limit), - RangeError, - ); - } - } finally { - await Deno.remove(root, { recursive: true }); - } - }); - - it("accepts a canonical candidate beneath a symlinked containment root", async () => { - if (Deno.build.os === "windows") return; - const workspace = await Deno.makeTempDir({ prefix: "vf-native-snapshot-root-" }); - const physicalRoot = `${workspace}/physical`; - const linkedRoot = `${workspace}/linked`; - try { - await Deno.mkdir(physicalRoot); - await Deno.writeFile(`${physicalRoot}/asset.bin`, new Uint8Array([1, 2, 3])); - await Deno.symlink(physicalRoot, linkedRoot); - const canonicalCandidate = await Deno.realPath(`${linkedRoot}/asset.bin`); - - assertEquals( - [...await readNodeFileSnapshotWithinLimit(canonicalCandidate, linkedRoot, 3)], - [1, 2, 3], - ); - } finally { - await Deno.remove(workspace, { recursive: true }); - } - }); - - it("rejects metadata oversize without reading and always closes the handle", async () => { - const stat = snapshotStat({ size: 4n }); - let reads = 0; - let closes = 0; - const handle = snapshotHandle(stat, { - read: () => { - reads++; - return Promise.resolve({ bytesRead: 0 }); - }, - close: () => { - closes++; - return Promise.resolve(); - }, - }); - - await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - stableOperations(stat, handle), - ), - RangeError, - ); - assertEquals({ reads, closes }, { reads: 0, closes: 1 }); - }); - - it("does not misclassify admitted allocation failure as byte-limit overflow", async () => { - const stat = snapshotStat({ size: BigInt(Number.MAX_SAFE_INTEGER) }); - const error = await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - Number.MAX_SAFE_INTEGER, - stableOperations(stat), - ), - Error, - "Unable to allocate", - ); - assertEquals(error instanceof RangeError, false); - }); - - it("normalizes open and initial handle-stat uncertainty with the original cause", async () => { - const stat = snapshotStat(); - const openFailure = new Error("removed before open"); - const openOperations: NativeSnapshotOperations = { - realpath: (path) => Promise.resolve(path), - lstat: () => Promise.resolve(stat), - open: () => Promise.reject(openFailure), - }; - const openError = await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - openOperations, - ), - FileSnapshotChangedError, - ) as FileSnapshotChangedError & { cause?: unknown }; - assertEquals(openError.cause, openFailure); - - const statFailure = new Error("opened identity unavailable"); - let closes = 0; - const statHandle = snapshotHandle(stat, { - stat: () => Promise.reject(statFailure), - close: () => { - closes++; - return Promise.resolve(); - }, - }); - const statError = await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - stableOperations(stat, statHandle), - ), - FileSnapshotChangedError, - ) as FileSnapshotChangedError & { cause?: unknown }; - assertEquals(statError.cause, statFailure); - assertEquals(closes, 1); - }); - - it("rejects pathname replacement and opened-file mutation", async () => { - const before = snapshotStat(); - const replacement = snapshotStat({ ino: 9n }); - let pathnameStats = 0; - const replacementOperations: NativeSnapshotOperations = { - realpath: (path) => Promise.resolve(path), - lstat: () => Promise.resolve(pathnameStats++ === 0 ? before : replacement), - open: () => Promise.resolve(snapshotHandle(before)), - }; - await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - replacementOperations, - ), - FileSnapshotChangedError, - ); - - const after = snapshotStat({ mtimeNs: 8n, ctimeNs: 9n }); - let handleStats = 0; - const mutatedHandle = snapshotHandle(before, { - stat: () => Promise.resolve(handleStats++ === 0 ? before : after), - }); - await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - stableOperations(before, mutatedHandle), - ), - FileSnapshotChangedError, - ); - }); - - it("preserves both a snapshot failure and a cleanup failure", async () => { - const stat = snapshotStat(); - const readFailure = new Error("read failed"); - const closeFailure = new Error("close failed"); - const handle = snapshotHandle(stat, { - read: () => Promise.reject(readFailure), - close: () => Promise.reject(closeFailure), - }); - const error = await assertRejects( - () => - readNodeFileSnapshotWithinLimit( - "/root/file.bin", - "/root", - 3, - stableOperations(stat, handle), - ), - AggregateError, - ) as AggregateError; - assertEquals(error.errors, [readFailure, closeFailure]); - }); - - it("creates exclusively without truncating collisions", async () => { - const root = await Deno.makeTempDir({ prefix: "vf-native-exclusive-" }); - try { - const created = `${root}/created.bin`; - const existing = `${root}/existing.bin`; - await Deno.writeFile(existing, new Uint8Array([9, 8, 7])); - - await createNodeFileBytesExclusive(created, new Uint8Array([0, 255, 1])); - assertEquals([...await Deno.readFile(created)], [0, 255, 1]); - await assertRejects( - () => createNodeFileBytesExclusive(existing, new Uint8Array([1])), - Error, - ); - assertEquals([...await Deno.readFile(existing)], [9, 8, 7]); - } finally { - await Deno.remove(root, { recursive: true }); - } - }); - - it("preserves exclusive-create write and cleanup failures without deleting ownership", async () => { - const writeFailure = new Error("write failed"); - const closeFailure = new Error("close failed"); - let opens = 0; - const stat = snapshotStat(); - const operations: NativeSnapshotOperations = { - realpath: (path) => Promise.resolve(path), - lstat: () => Promise.resolve(stat), - open: (_path, flags) => { - assertEquals(flags, "wx"); - opens++; - return Promise.resolve(snapshotHandle(stat, { - writeFile: () => Promise.reject(writeFailure), - close: () => Promise.reject(closeFailure), - })); - }, - }; - const error = await assertRejects( - () => createNodeFileBytesExclusive("/reserved.bin", new Uint8Array([1]), operations), - AggregateError, - ) as AggregateError; - assertEquals(opens, 1); - assertEquals(error.errors, [writeFailure, closeFailure]); - }); -}); diff --git a/src/platform/adapters/runtime/shared/native-file-capabilities.ts b/src/platform/adapters/runtime/shared/native-file-capabilities.ts deleted file mode 100644 index cfa7b67666..0000000000 --- a/src/platform/adapters/runtime/shared/native-file-capabilities.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { isAbsolute, relative, resolve, sep } from "../../../compat/path/index.ts"; -import { runtimeUsesWindowsPaths } from "../../../compat/path/portable.ts"; -import { FileSnapshotChangedError } from "../../file-snapshot-error.ts"; - -interface SnapshotStat { - readonly dev: bigint; - readonly ino: bigint; - readonly size: bigint; - readonly mtimeNs: bigint; - readonly ctimeNs: bigint; - isFile(): boolean; - isSymbolicLink(): boolean; -} - -interface SnapshotHandle { - stat(): Promise; - read( - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ): Promise<{ bytesRead: number }>; - writeFile(content: Uint8Array): Promise; - close(): Promise; -} - -export interface NativeSnapshotOperations { - realpath(path: string): Promise; - lstat(path: string): Promise; - open(path: string, flags: number | string): Promise; -} - -/** Whether the runtime can enforce a no-follow native snapshot open. */ -export function supportsNativeFileSnapshots( - platform: "posix" | "windows" = runtimeUsesWindowsPaths() ? "windows" : "posix", -): boolean { - return platform === "posix"; -} - -function toSnapshotStat(stats: import("node:fs").BigIntStats): SnapshotStat { - return { - dev: stats.dev, - ino: stats.ino, - size: stats.size, - mtimeNs: stats.mtimeNs, - ctimeNs: stats.ctimeNs, - isFile: () => stats.isFile(), - isSymbolicLink: () => stats.isSymbolicLink(), - }; -} - -async function defaultOperations(): Promise { - const fs = await import("node:fs/promises"); - return { - realpath: (path) => fs.realpath(path), - async lstat(path) { - return toSnapshotStat(await fs.lstat(path, { bigint: true })); - }, - async open(path, flags) { - const handle = await fs.open(path, flags); - return { - async stat() { - return toSnapshotStat(await handle.stat({ bigint: true })); - }, - read: (buffer, offset, length, position) => handle.read(buffer, offset, length, position), - writeFile: (content) => handle.writeFile(content), - close: () => handle.close(), - }; - }, - }; -} - -function requireByteLimit(value: number): number { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new RangeError("Snapshot byte limit must be a positive safe integer"); - } - return value; -} - -function isContainedPath(path: string, root: string): boolean { - const relation = relative(root, path); - return relation === "" || - (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation)); -} - -function sameIdentity(left: SnapshotStat, right: SnapshotStat): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -function sameGeneration(left: SnapshotStat, right: SnapshotStat): boolean { - return sameIdentity(left, right) && left.size === right.size && - left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs; -} - -function changed(message: string, cause?: unknown): FileSnapshotChangedError { - const error = new FileSnapshotChangedError(message); - if (cause !== undefined) Object.defineProperty(error, "cause", { value: cause }); - return error; -} - -async function closeSnapshotHandle( - handle: SnapshotHandle, - primaryFailure: unknown, - failed: boolean, -): Promise { - try { - await handle.close(); - } catch (cleanupFailure) { - if (failed) { - throw new AggregateError( - [primaryFailure, cleanupFailure], - "Filesystem snapshot read and handle cleanup both failed", - ); - } - throw cleanupFailure; - } - if (failed) throw primaryFailure; -} - -/** - * Read one verified regular-file generation without following a terminal link. - * Identity and generation are checked before and after the bounded positional read. - */ -export async function readNodeFileSnapshotWithinLimit( - path: string, - containmentRoot: string, - byteLimit: number, - operations?: NativeSnapshotOperations, -): Promise { - const admittedLimit = requireByteLimit(byteLimit); - const lexicalRoot = resolve(containmentRoot); - const candidate = resolve(path); - - const fsOperations = operations ?? await defaultOperations(); - const { constants } = await import("node:fs"); - if (!Number.isSafeInteger(constants.O_NOFOLLOW) || constants.O_NOFOLLOW === 0) { - throw new TypeError("This runtime cannot guarantee no-follow snapshot opens"); - } - - const canonicalRoot = await fsOperations.realpath(lexicalRoot); - if ( - !isContainedPath(candidate, lexicalRoot) && - !isContainedPath(candidate, canonicalRoot) - ) { - throw new TypeError("Snapshot path must be contained by the requested root"); - } - const pathnameBefore = await fsOperations.lstat(candidate); - if (pathnameBefore.isSymbolicLink()) { - throw new TypeError("Snapshot path must not be a symbolic link"); - } - if (!pathnameBefore.isFile()) { - throw new TypeError("Snapshot path must identify a regular file"); - } - - let handle: SnapshotHandle; - try { - handle = await fsOperations.open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); - } catch (cause) { - throw changed("File identity became uncertain while opening the snapshot", cause); - } - - let failed = false; - let primaryFailure: unknown; - let result: Uint8Array | undefined; - try { - let handleBefore: SnapshotStat; - try { - handleBefore = await handle.stat(); - } catch (cause) { - throw changed("Opened file identity could not be verified", cause); - } - if (!handleBefore.isFile() || !sameGeneration(pathnameBefore, handleBefore)) { - throw changed("File identity changed while opening the snapshot"); - } - - let canonicalTarget: string; - let pathnameOpened: SnapshotStat; - try { - [canonicalTarget, pathnameOpened] = await Promise.all([ - fsOperations.realpath(candidate), - fsOperations.lstat(candidate), - ]); - } catch (cause) { - throw changed("File target became uncertain while opening the snapshot", cause); - } - if ( - pathnameOpened.isSymbolicLink() || !pathnameOpened.isFile() || - !sameGeneration(handleBefore, pathnameOpened) - ) { - throw changed("File identity changed while opening the snapshot"); - } - if (!isContainedPath(canonicalTarget, canonicalRoot)) { - throw new TypeError("Snapshot target must be contained by the canonical root"); - } - if (handleBefore.size < 0n) { - throw changed("File size became uncertain while opening the snapshot"); - } - if (handleBefore.size > BigInt(admittedLimit)) { - throw new RangeError(`File exceeds byte limit of ${admittedLimit} bytes`); - } - - const size = Number(handleBefore.size); - let bytes: Uint8Array; - try { - bytes = new Uint8Array(size); - } catch (cause) { - throw new Error("Unable to allocate the admitted snapshot buffer", { cause }); - } - let offset = 0; - while (offset < size) { - const { bytesRead } = await handle.read(bytes, offset, size - offset, offset); - if (!Number.isSafeInteger(bytesRead) || bytesRead <= 0 || bytesRead > size - offset) { - throw changed("File size changed while reading the snapshot"); - } - offset += bytesRead; - } - - let handleAfter: SnapshotStat; - let pathnameAfter: SnapshotStat; - let canonicalTargetAfter: string; - try { - [handleAfter, pathnameAfter, canonicalTargetAfter] = await Promise.all([ - handle.stat(), - fsOperations.lstat(candidate), - fsOperations.realpath(candidate), - ]); - } catch (cause) { - throw changed("File identity became uncertain after reading the snapshot", cause); - } - if ( - pathnameAfter.isSymbolicLink() || !pathnameAfter.isFile() || - !sameGeneration(handleBefore, handleAfter) || - !sameGeneration(handleBefore, pathnameAfter) || - canonicalTargetAfter !== canonicalTarget || - !isContainedPath(canonicalTargetAfter, canonicalRoot) - ) { - throw changed("File snapshot changed during the read"); - } - result = bytes; - } catch (error) { - failed = true; - primaryFailure = error; - } - - await closeSnapshotHandle(handle, primaryFailure, failed); - return result!; -} - -/** Create a new file without replacement and join handle cleanup. */ -export async function createNodeFileBytesExclusive( - path: string, - content: Uint8Array, - operations?: NativeSnapshotOperations, -): Promise { - const fsOperations = operations ?? await defaultOperations(); - const handle = await fsOperations.open(path, "wx"); - let failed = false; - let primaryFailure: unknown; - try { - await handle.writeFile(content); - } catch (error) { - failed = true; - primaryFailure = error; - } - await closeSnapshotHandle(handle, primaryFailure, failed); -} diff --git a/src/platform/adapters/runtime/shared/native-snapshot-identity.ts b/src/platform/adapters/runtime/shared/native-snapshot-identity.ts new file mode 100644 index 0000000000..45f4b12edb --- /dev/null +++ b/src/platform/adapters/runtime/shared/native-snapshot-identity.ts @@ -0,0 +1,17 @@ +/** Native path semantics used by exact file-snapshot adapters. */ +export type NativeSnapshotPlatform = "posix" | "windows"; + +/** + * File identity must distinguish an opened handle from a pathname replacement. + * Some filesystems report a zero inode/file-index when that guarantee is not + * available. Exact snapshots fail closed instead of treating that value as an + * identity shared by unrelated files. + */ +export function hasUsableNativeFileIdentity( + stat: Readonly<{ dev: bigint; ino: bigint }>, +): boolean { + // libuv maps Windows st_dev to the volume serial number and may report zero + // when the volume query is unsupported. A positive device/volume identity is + // required so equal file indices on different volumes cannot compare equal. + return stat.dev > 0n && stat.ino > 0n; +} diff --git a/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts b/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts index dda08ae876..5cc6a5bf0c 100644 --- a/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/shared/node-filesystem-adapter.test.ts @@ -8,11 +8,16 @@ import { import { describe, it } from "#veryfront/testing/bdd.ts"; import { FileSnapshotChangedError } from "../../file-snapshot-error.ts"; import { isNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; -import { NodeCompatibleFileSystemAdapter } from "./node-filesystem-adapter.ts"; +import { + hasUsableWindowsSnapshotIdentity, + NodeCompatibleFileSystemAdapter, + readNodeFileSnapshotWithinLimit, +} from "./node-filesystem-adapter.ts"; import { setupNodeFsWatcher } from "./shared-watcher.ts"; type AdapterOptions = { noFollow?: number; + platform?: "posix" | "windows"; exclusiveCreate?: boolean; operations?: Record; }; @@ -64,15 +69,234 @@ describe("NodeCompatibleFileSystemAdapter", () => { } }); - it("omits snapshot authority when O_NOFOLLOW is absent or zero", () => { + it("accepts a canonical candidate beneath a symlinked containment root", async () => { + if (Deno.build.os === "windows") return; + const workspace = await Deno.makeTempDir({ prefix: "veryfront-node-snapshot-root-" }); + const physicalRoot = `${workspace}/physical`; + const linkedRoot = `${workspace}/linked`; + try { + await Deno.mkdir(physicalRoot); + await Deno.writeFile(`${physicalRoot}/asset.bin`, new Uint8Array([1, 2, 3])); + await Deno.symlink(physicalRoot, linkedRoot); + const canonicalCandidate = await Deno.realPath(`${linkedRoot}/asset.bin`); + + const readSnapshot = requireSnapshotReader(new NodeCompatibleFileSystemAdapter()); + assertEquals([...await readSnapshot(canonicalCandidate, linkedRoot, 3)], [1, 2, 3]); + } finally { + await Deno.remove(workspace, { recursive: true }); + } + }); + + it("omits snapshot authority on POSIX when O_NOFOLLOW is absent or zero", () => { for (const noFollow of [undefined, 0]) { - const adapter = new TestableNodeCompatibleFileSystemAdapter(undefined, { noFollow }); + const adapter = new TestableNodeCompatibleFileSystemAdapter(undefined, { + noFollow, + platform: "posix", + }); assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); assertEquals(adapter.readFileSnapshotWithinLimit, undefined); assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); } }); + it("reads an exact Windows snapshot through an identity-verified handle", async () => { + const source = new Uint8Array([4, 5, 6]); + const stat = { + dev: 1n, + ino: 2n, + size: BigInt(source.byteLength), + mtimeNs: 4n, + ctimeNs: 5n, + isFile: () => true, + isSymbolicLink: () => false, + }; + const openedWith: Array = []; + const operations = { + realpath: (path: string) => Promise.resolve(path), + lstat: () => Promise.resolve(stat), + open: (_path: string, flags: number | string) => { + openedWith.push(flags); + return Promise.resolve({ + stat: () => Promise.resolve(stat), + read: (buffer: Uint8Array, offset: number, length: number, position: number) => { + buffer.set(source.subarray(position, position + length), offset); + return Promise.resolve({ bytesRead: length }); + }, + writeFile: () => Promise.resolve(), + close: () => Promise.resolve(), + }); + }, + }; + assertEquals( + [ + ...await readNodeFileSnapshotWithinLimit( + operations, + "windows", + 0, + "C:\\root\\file.bin", + "C:\\root", + 3, + ), + ], + [4, 5, 6], + ); + assertEquals(openedWith, ["r"]); + }); + + it("rejects a Windows lexical containment escape before candidate filesystem access", async () => { + let operationCalls = 0; + const operations = { + realpath: () => { + operationCalls++; + return Promise.resolve("C:/root"); + }, + lstat: () => { + operationCalls++; + throw new Error("outside paths must not be inspected"); + }, + open: () => { + operationCalls++; + throw new Error("outside paths must not be opened"); + }, + }; + + await assertRejects( + () => + readNodeFileSnapshotWithinLimit( + operations, + "windows", + 0, + "C:\\outside\\file.bin", + "C:\\root", + 1, + ), + TypeError, + "Snapshot path must be contained", + ); + // Canonicalizing the trusted root is required to admit canonical candidates + // beneath symlinked roots. The untrusted candidate is never inspected. + assertEquals(operationCalls, 1); + }); + + it("rejects a Windows canonical target outside the containment root", async () => { + const source = new Uint8Array([7]); + const stat = { + dev: 1n, + ino: 2n, + size: 1n, + mtimeNs: 4n, + ctimeNs: 5n, + isFile: () => true, + isSymbolicLink: () => false, + }; + let realpathCalls = 0; + let closeCalls = 0; + const operations = { + realpath: () => + Promise.resolve( + realpathCalls++ === 0 ? "C:/root" : "C:/outside/file.bin", + ), + lstat: () => Promise.resolve(stat), + open: () => + Promise.resolve({ + stat: () => Promise.resolve(stat), + read: (buffer: Uint8Array) => { + buffer.set(source); + return Promise.resolve({ bytesRead: source.byteLength }); + }, + writeFile: () => Promise.resolve(), + close: () => { + closeCalls++; + return Promise.resolve(); + }, + }), + }; + + await assertRejects( + () => + readNodeFileSnapshotWithinLimit( + operations, + "windows", + 0, + "C:\\root\\linked\\file.bin", + "C:\\root", + 1, + ), + TypeError, + "Snapshot target must be contained", + ); + assertEquals(closeCalls, 1); + }); + + it("fails closed when Windows cannot provide a stable native file identity", async () => { + let opens = 0; + const stat = { + dev: 0n, + ino: 2n, + size: 1n, + mtimeNs: 4n, + ctimeNs: 5n, + isFile: () => true, + isSymbolicLink: () => false, + }; + const operations = { + realpath: (path: string) => Promise.resolve(path), + lstat: () => Promise.resolve(stat), + open: () => { + opens++; + throw new Error("must not open without an identity"); + }, + }; + + await assertRejects( + () => + readNodeFileSnapshotWithinLimit( + operations, + "windows", + 0, + "C:\\root\\file.bin", + "C:\\root", + 1, + ), + FileSnapshotChangedError, + "Stable native file identity is unavailable", + ); + assertEquals(opens, 0); + }); + + it("publishes Windows snapshot authority only for Node's usable identity contract", () => { + assertEquals(hasUsableWindowsSnapshotIdentity("node"), true); + for (const runtime of ["bun", "deno", "unknown"] as const) { + assertEquals(hasUsableWindowsSnapshotIdentity(runtime), false); + } + + // This suite runs under Deno, so forcing Windows path semantics must not + // turn the shared adapter into a Node-provenance publisher. + const adapter = new TestableNodeCompatibleFileSystemAdapter(undefined, { + noFollow: 0, + platform: "windows", + operations: { + realpath: (path: string) => Promise.resolve(path), + lstat: () => + Promise.resolve({ + dev: 1n, + ino: 2n, + size: 0n, + mtimeNs: 3n, + ctimeNs: 4n, + isFile: () => true, + isSymbolicLink: () => false, + }), + open: () => { + throw new Error("unpublished capability must not open files"); + }, + }, + }); + assertEquals(Object.hasOwn(adapter, "readFileSnapshotWithinLimit"), false); + assertEquals(adapter.readFileSnapshotWithinLimit, undefined); + assertEquals(Object.hasOwn(adapter, "createFileBytesExclusive"), true); + }); + it("omits exclusive create independently from available snapshot authority", () => { const adapter = new TestableNodeCompatibleFileSystemAdapter(undefined, { noFollow: 1, diff --git a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts index eeee5bda15..f03681905e 100644 --- a/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts +++ b/src/platform/adapters/runtime/shared/node-filesystem-adapter.ts @@ -17,8 +17,14 @@ import { } from "../../bounded-file-read.ts"; import { markNativeFileSystemAdapter } from "../../native-file-system-provenance.ts"; import { constants as nodeFsConstants } from "node:fs"; -import { isAbsolute, relative, resolve, sep } from "../../../compat/path/index.ts"; -import { FileSnapshotChangedError } from "../../file-snapshot-error.ts"; +import { resolve } from "../../../compat/path/index.ts"; +import { runtimeUsesWindowsPaths } from "../../../compat/path/portable.ts"; +import { FileSnapshotChangedError, FileSnapshotPathError } from "../../file-snapshot-error.ts"; +import { + hasUsableNativeFileIdentity, + type NativeSnapshotPlatform, +} from "./native-snapshot-identity.ts"; +import { isPathContainedBy } from "../../path-containment.ts"; export interface NodeFileSystemLogger { error(message: string, context?: Record): void; @@ -64,12 +70,17 @@ export interface NodeFileSystemOperations { export interface NodeFileSystemCapabilityOptions { /** Test seam for runtime constants. An own undefined value means unavailable. */ readonly noFollow?: number; + /** Test seam for native open and path semantics. */ + readonly platform?: NativeSnapshotPlatform; /** Test seam for create-new primitive availability. */ readonly exclusiveCreate?: boolean; /** Test seam for deterministic filesystem races and write failures. */ readonly operations?: Partial; } +/** Runtime whose Node-compatible filesystem implementation backs the adapter. */ +export type NodeCompatibleRuntimeProvenance = "node" | "bun" | "deno" | "unknown"; + function toSnapshotStat(stats: import("node:fs").BigIntStats): NodeFileSnapshotStat { return { dev: stats.dev, @@ -115,10 +126,35 @@ function hasOwn(value: object, property: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(value, property); } -function isContainedPath(path: string, root: string): boolean { - const relation = relative(root, path); - return relation === "" || - (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation)); +function detectNodeCompatibleRuntime(): NodeCompatibleRuntimeProvenance { + const runtime = globalThis as typeof globalThis & { + Bun?: unknown; + Deno?: unknown; + process?: { + release?: { name?: string }; + versions?: { bun?: string; deno?: string; node?: string }; + }; + }; + const versions = runtime.process?.versions; + if (typeof versions?.deno === "string" || runtime.Deno !== undefined) return "deno"; + if (typeof versions?.bun === "string" || runtime.Bun !== undefined) return "bun"; + if ( + runtime.process?.release?.name === "node" && + typeof versions?.node === "string" + ) { + return "node"; + } + return "unknown"; +} + +export function hasUsableWindowsSnapshotIdentity( + runtime: NodeCompatibleRuntimeProvenance, +): boolean { + // Node exposes bigint file identity and generation fields on Windows. Each + // snapshot still validates that the native identity is present and usable. + // Bun and Deno do not currently document an equivalent contract, so their + // Windows adapters must fail closed. + return runtime === "node"; } function requirePositiveSafeInteger(value: number): void { @@ -138,6 +174,12 @@ function sameGeneration(left: NodeFileSnapshotStat, right: NodeFileSnapshotStat) left.ctimeNs === right.ctimeNs; } +function requireUsableIdentity(stat: NodeFileSnapshotStat, message: string): void { + if (!hasUsableNativeFileIdentity(stat)) { + throw changed(message); + } +} + function changed(message: string, cause?: unknown): FileSnapshotChangedError { const error = new FileSnapshotChangedError(message); if (cause !== undefined) { @@ -174,33 +216,50 @@ function throwSnapshotChangeForPathRace(message: string, cause: unknown): never throwSnapshotChangeForMissingPath(message, cause); } -async function readNodeFileSnapshotWithinLimit( +export async function readNodeFileSnapshotWithinLimit( operations: NodeFileSystemOperations, - noFollow: number, + platform: NativeSnapshotPlatform, + noFollow: number | undefined, path: string, containmentRoot: string, byteLimit: number, ): Promise { requirePositiveSafeInteger(byteLimit); + let openFlags: number | string; + if (platform === "windows") { + openFlags = "r"; + } else { + if (typeof noFollow !== "number" || noFollow === 0) { + throw new TypeError("This runtime cannot guarantee no-follow snapshot opens"); + } + openFlags = nodeFsConstants.O_RDONLY | noFollow; + } const lexicalRoot = resolve(containmentRoot); const candidate = resolve(path); - if (!isContainedPath(candidate, lexicalRoot)) { - throw new TypeError("Snapshot path must be contained by the requested root"); + const canonicalRoot = await operations.realpath(lexicalRoot); + if ( + !isPathContainedBy(candidate, lexicalRoot) && + !isPathContainedBy(candidate, canonicalRoot) + ) { + throw new FileSnapshotPathError("Snapshot path must be contained by the requested root"); } - const canonicalRoot = await operations.realpath(lexicalRoot); const pathnameBefore = await operations.lstat(candidate); if (pathnameBefore.isSymbolicLink()) { - throw new TypeError("Snapshot path must not be a symbolic link"); + throw new FileSnapshotPathError("Snapshot path must not be a symbolic link"); } if (!pathnameBefore.isFile()) { - throw new TypeError("Snapshot path must identify a regular file"); + throw new FileSnapshotPathError("Snapshot path must identify a regular file"); } + requireUsableIdentity( + pathnameBefore, + "Stable native file identity is unavailable for the snapshot path", + ); return await withFileHandle( async () => { try { - return await operations.open(candidate, nodeFsConstants.O_RDONLY | noFollow); + return await operations.open(candidate, openFlags); } catch (cause) { throwSnapshotChangeForPathRace( "File identity became uncertain while opening the snapshot", @@ -215,6 +274,10 @@ async function readNodeFileSnapshotWithinLimit( } catch (cause) { throwSnapshotChangeForMissingPath("Opened file identity could not be verified", cause); } + requireUsableIdentity( + handleBefore, + "Stable native file identity is unavailable for the opened snapshot", + ); if (!handleBefore.isFile() || !sameGeneration(pathnameBefore, handleBefore)) { throw changed("File identity changed while opening the snapshot"); } @@ -239,8 +302,14 @@ async function readNodeFileSnapshotWithinLimit( ) { throw changed("File identity changed while opening the snapshot"); } - if (!isContainedPath(canonicalTarget, canonicalRoot)) { - throw new TypeError("Snapshot target must be contained by the canonical root"); + requireUsableIdentity( + pathnameOpened, + "Stable native file identity is unavailable while verifying the snapshot", + ); + if (!isPathContainedBy(canonicalTarget, canonicalRoot)) { + throw new FileSnapshotPathError( + "Snapshot target must be contained by the canonical root", + ); } if (handleBefore.size < 0n) { @@ -288,13 +357,21 @@ async function readNodeFileSnapshotWithinLimit( cause, ); } + requireUsableIdentity( + handleAfter, + "Stable native file identity is unavailable after reading the snapshot", + ); + requireUsableIdentity( + pathnameAfter, + "Stable native file identity is unavailable after reading the snapshot path", + ); if ( !pathnameAfter.isFile() || pathnameAfter.isSymbolicLink() || !sameGeneration(handleBefore, handleAfter) || !sameGeneration(handleBefore, pathnameAfter) || canonicalTargetAfter !== canonicalTarget || - !isContainedPath(canonicalTargetAfter, canonicalRoot) + !isPathContainedBy(canonicalTargetAfter, canonicalRoot) ) { throw changed("File snapshot changed during the read"); } @@ -330,11 +407,16 @@ export class NodeCompatibleFileSystemAdapter implements FileSystemAdapter { ...options.operations, } as NodeFileSystemOperations; const noFollow = hasOwn(options, "noFollow") ? options.noFollow : nodeFsConstants.O_NOFOLLOW; - if (typeof noFollow === "number" && noFollow !== 0) { + const platform = options.platform ?? (runtimeUsesWindowsPaths() ? "windows" : "posix"); + const canOpenExactSnapshot = platform === "windows" + ? hasUsableWindowsSnapshotIdentity(detectNodeCompatibleRuntime()) + : typeof noFollow === "number" && noFollow !== 0; + if (canOpenExactSnapshot) { Object.defineProperty(this, "readFileSnapshotWithinLimit", { value: (path: string, containmentRoot: string, byteLimit: number) => readNodeFileSnapshotWithinLimit( operations, + platform, noFollow, path, containmentRoot, diff --git a/src/provider/veryfront-cloud/shared.test.ts b/src/provider/veryfront-cloud/shared.test.ts index dde2352a6d..e8114a8cea 100644 --- a/src/provider/veryfront-cloud/shared.test.ts +++ b/src/provider/veryfront-cloud/shared.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; -import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { runWithVeryfrontCloudContext } from "#veryfront/provider"; import { createVeryfrontCloudFetch, @@ -9,12 +10,6 @@ import { } from "./shared.ts"; describe("provider/veryfront-cloud/shared", () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - it("normalizes provider aliases when parsing model IDs", () => { assertEquals( parseVeryfrontCloudModelId("google-ai-studio/gemini-2.0-flash", "embedding"), @@ -148,26 +143,29 @@ describe("provider/veryfront-cloud/shared", () => { it("rewrites auth headers for the gateway fetch wrapper", async () => { let capturedRequest: Request | undefined; - globalThis.fetch = ((input: URL | Request | string, init?: RequestInit) => { - capturedRequest = new Request(input, init); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof fetch; const wrappedFetch = createVeryfrontCloudFetch( "vf_test_provider", "https://93.184.216.34/ai/gateway/openai/v1", ); - await wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions", { - headers: { - Authorization: "Bearer upstream-token", - "x-api-key": "anthropic-key", - "x-goog-api-key": "google-key", - "x-veryfront-project-slug": "spoofed-project", - "x-veryfront-billing-group-id": "spoofed-billing-group", - "x-extra-header": "kept", + await withMockFetch( + async (input: URL | Request | string, init?: RequestInit) => { + capturedRequest = new Request(input, init); + return new Response(null, { status: 204 }); }, - }); + () => + wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions", { + headers: { + Authorization: "Bearer upstream-token", + "x-api-key": "anthropic-key", + "x-goog-api-key": "google-key", + "x-veryfront-project-slug": "spoofed-project", + "x-veryfront-billing-group-id": "spoofed-billing-group", + "x-extra-header": "kept", + }, + }), + ); assertEquals(capturedRequest?.headers.get("Authorization"), "Bearer vf_test_provider"); assertEquals(capturedRequest?.headers.get("x-api-key"), null); @@ -179,10 +177,6 @@ describe("provider/veryfront-cloud/shared", () => { it("replaces caller identity headers with trusted project and billing context", async () => { let capturedRequest: Request | undefined; - globalThis.fetch = ((input: URL | Request | string, init?: RequestInit) => { - capturedRequest = new Request(input, init); - return Promise.resolve(new Response(null, { status: 204 })); - }) as typeof fetch; const wrappedFetch = createVeryfrontCloudFetch( "vf_test_provider", @@ -190,15 +184,22 @@ describe("provider/veryfront-cloud/shared", () => { "trusted-project", ); - await runWithVeryfrontCloudContext( - { billingGroupId: "evalrun_20260628_kimi" }, + await withMockFetch( + async (input: URL | Request | string, init?: RequestInit) => { + capturedRequest = new Request(input, init); + return new Response(null, { status: 204 }); + }, () => - wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions", { - headers: { - "x-veryfront-project-slug": "spoofed-project", - "x-veryfront-billing-group-id": "spoofed-billing-group", - }, - }), + runWithVeryfrontCloudContext( + { billingGroupId: "evalrun_20260628_kimi" }, + () => + wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions", { + headers: { + "x-veryfront-project-slug": "spoofed-project", + "x-veryfront-billing-group-id": "spoofed-billing-group", + }, + }), + ), ); assertEquals( @@ -213,24 +214,25 @@ describe("provider/veryfront-cloud/shared", () => { it("rejects redirects before the gateway credential reaches another origin", async () => { const seen: Request[] = []; - globalThis.fetch = ((input: URL | Request | string, init?: RequestInit) => { - seen.push(new Request(input, init)); - return Promise.resolve( - new Response(null, { - status: 302, - headers: { location: "https://93.184.216.35/steal" }, - }), - ); - }) as typeof fetch; const wrappedFetch = createVeryfrontCloudFetch( "vf_test_provider", "https://93.184.216.34/ai/gateway/openai/v1", ); - await assertRejects( - () => wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions"), - Error, - "redirect", + await withMockFetch( + async (input: URL | Request | string, init?: RequestInit) => { + seen.push(new Request(input, init)); + return new Response(null, { + status: 302, + headers: { location: "https://93.184.216.35/steal" }, + }); + }, + () => + assertRejects( + () => wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions"), + Error, + "redirect", + ), ); assertEquals(seen.length, 1); assertEquals(seen[0]?.headers.get("authorization"), "Bearer vf_test_provider"); diff --git a/src/react/components/ui/anchored-surface.test.tsx b/src/react/components/ui/anchored-surface.test.tsx index cfeb6a8791..b0df5366cb 100644 --- a/src/react/components/ui/anchored-surface.test.tsx +++ b/src/react/components/ui/anchored-surface.test.tsx @@ -200,6 +200,7 @@ describe("anchored surfaces anchor to the trigger ref", () => { assertEquals(selectedAnchor.current?.tagName, "A"); } finally { flushSync(() => root.unmount()); + await new Promise((resolve) => setTimeout(resolve, 0)); restore(); } }); @@ -286,6 +287,7 @@ describe("anchored surfaces anchor to the trigger ref", () => { }; } finally { flushSync(() => root.unmount()); + await new Promise((resolve) => setTimeout(resolve, 0)); restore(); } } diff --git a/src/rendering/app-route-resolver.test.ts b/src/rendering/app-route-resolver.test.ts index 0bd7b273d5..a4cc917aaa 100644 --- a/src/rendering/app-route-resolver.test.ts +++ b/src/rendering/app-route-resolver.test.ts @@ -12,13 +12,25 @@ function createMockAdapter( return { fs: { + symlinkSemantics: "none", readFile: (path: string) => { const content = files.get(path); if (content === undefined) { - return Promise.reject(new Error(`ENOENT: ${path}`)); + return Promise.reject(Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" })); } return Promise.resolve(content); }, + readFileBytesWithinLimit: (path: string, byteLimit: number) => { + const content = files.get(path); + if (content === undefined) { + return Promise.reject(Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" })); + } + const bytes = new TextEncoder().encode(content); + if (bytes.byteLength > byteLimit) { + return Promise.reject(new RangeError(`File exceeds ${byteLimit} bytes`)); + } + return Promise.resolve(bytes); + }, stat: (path: string) => { if (files.has(path)) { return Promise.resolve({ isFile: true, isDirectory: false }); @@ -158,6 +170,146 @@ describe("rendering/app-route-resolver", () => { assertEquals(result?.entity.slug, "blog/hello-world"); }); + it("resolves a nested optional catch-all page at its terminal directory", async () => { + const files = new Map([ + ["/project/app/docs/[[...slug]]/page.mdx", "---\ntitle: Docs\n---\nDocs page"], + ]); + const dirs = new Set([ + "/project/app", + "/project/app/docs", + "/project/app/docs/[[...slug]]", + ]); + const adapter = createMockAdapter(files, dirs); + + const docs = await getAppRouteEntity("/project", "docs", adapter); + const nestedDocs = await getAppRouteEntity("/project", "docs/a", adapter); + + assertEquals(docs?.entity.slug, "docs"); + assertEquals(nestedDocs?.entity.slug, "docs/a"); + }); + + it("rejects traversal slugs before consulting the filesystem", async () => { + const adapter = createMockAdapter(new Map()); + let resolveCalls = 0; + adapter.fs.resolveFile = () => { + resolveCalls++; + return Promise.resolve("/outside/page.mdx"); + }; + + assertEquals( + await getAppRouteEntity("/project", "../../outside", adapter), + null, + ); + assertEquals(resolveCalls, 0); + }); + + it("does not read an adapter-resolved page outside the App Router root", async () => { + const adapter = createMockAdapter(new Map()); + let reads = 0; + adapter.fs.resolveFile = () => Promise.resolve("/outside/page.mdx"); + adapter.fs.readFileBytesWithinLimit = () => { + reads++; + return Promise.resolve(new TextEncoder().encode("# Secret")); + }; + + assertEquals(await getAppRouteEntity("/project", "about", adapter), null); + assertEquals(reads, 0); + }); + + it("preserves adapter read failures instead of reporting a missing page", async () => { + const outage = Object.freeze({ code: "ENOENT", detail: "remote source unavailable" }); + const adapter = createMockAdapter(new Map()); + adapter.fs.resolveFile = (path: string) => + Promise.resolve(path.endsWith("/page") ? `${path}.mdx` : null); + adapter.fs.readFileBytesWithinLimit = () => Promise.reject(outage); + + let caught: unknown; + try { + await getAppRouteEntity("/project", "about", adapter); + } catch (error) { + caught = error; + } + assertEquals(caught === outage, true); + }); + + it("preserves adapter directory failures during dynamic discovery", async () => { + const outage = Object.freeze({ code: "ENOENT", detail: "directory service unavailable" }); + const adapter = createMockAdapter(new Map()); + adapter.fs.readDir = () => ({ + [Symbol.asyncIterator]() { + return { + next: () => Promise.reject(outage), + }; + }, + }); + + let caught: unknown; + try { + await getAppRouteEntity("/project", "article", adapter); + } catch (error) { + caught = error; + } + assertEquals(caught === outage, true); + }); + + it("does not treat file-suffixed names as dynamic route directories", async () => { + const files = new Map([ + ["/project/app/[id].tsx/page.tsx", "export default function Page() {}"], + ]); + const dirs = new Set(["/project/app", "/project/app/[id].tsx"]); + + assertEquals( + await getAppRouteEntity("/project", "article", createMockAdapter(files, dirs)), + null, + ); + }); + + it("uses the bounded reader and never the raw text reader", async () => { + const adapter = createMockAdapter( + new Map([ + ["/project/app/page.mdx", "# Page"], + ]), + ); + let rawReads = 0; + adapter.fs.readFile = () => { + rawReads++; + return Promise.resolve("# Unbounded page"); + }; + + const result = await getAppRouteEntity("/project", "", adapter); + + assertEquals(result?.entity.content, "# Page"); + assertEquals(rawReads, 0); + }); + + it("binds link-resolving App Router reads to the App root snapshot", async () => { + const adapter = createMockAdapter(new Map()); + Reflect.deleteProperty(adapter.fs, "symlinkSemantics"); + let boundedReads = 0; + const snapshotCalls: Array<[string, string, number]> = []; + adapter.fs.readFileBytesWithinLimit = () => { + boundedReads++; + return Promise.resolve(new TextEncoder().encode("# Unbound")); + }; + adapter.fs.readFileSnapshotWithinLimit = ( + path: string, + root: string, + byteLimit: number, + ) => { + snapshotCalls.push([path, root, byteLimit]); + return Promise.resolve(new TextEncoder().encode("# Bound")); + }; + + const result = await getAppRouteEntity("/project", "", adapter); + + assertEquals(result?.entity.content, "# Bound"); + assertEquals(boundedReads, 0); + assertEquals(snapshotCalls.length, 1); + assertEquals(snapshotCalls[0]?.[0], "/project/app/page.mdx"); + assertEquals(snapshotCalls[0]?.[1], "/project/app"); + assertEquals(Number(snapshotCalls[0]?.[2]) > 0, true); + }); + it("should convert boolean layout frontmatter to string", async () => { const files = new Map([ ["/project/app/page.mdx", "---\nlayout: true\n---\nContent"], diff --git a/src/rendering/app-route-resolver.ts b/src/rendering/app-route-resolver.ts index 11b53c4ee3..200a6daa10 100644 --- a/src/rendering/app-route-resolver.ts +++ b/src/rendering/app-route-resolver.ts @@ -1,66 +1,125 @@ /** * App Router Entity Resolution * - * Handles resolution of App Router page entities, including: - * - Exact route matching - * - Dynamic segment matching ([id], [...slug], etc.) - * - Page file loading with frontmatter extraction + * Resolves exact and parameterized App Router pages through the same captured, + * bounded filesystem authority used by Pages Router entity discovery. */ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; import type { EntityInfo, Frontmatter } from "#veryfront/types"; -import { isCatchAllSegment, isDynamicSegment } from "#veryfront/utils/route-path-utils.ts"; -import { join } from "#veryfront/compat/path"; -import { extract } from "#std/front-matter/yaml.ts"; +import { + type EntityResolutionOptions, + type EntityResolutionSession, + withEntityResolutionAdmission, +} from "#veryfront/types/entities/getEntityInfo.ts"; +import { + containsPathControlCharacters, + parseRouteParameterSegment, + type RouteParameterKind, +} from "#veryfront/utils/route-path-utils.ts"; +import { MAX_PATH_LENGTH_CHARS, MAX_ROUTE_SEGMENTS } from "#veryfront/utils/constants/limits.ts"; +import { ROUTE_CONFLICT } from "#veryfront/errors/error-registry/route.ts"; +import { isAbsolute, join } from "#veryfront/compat/path"; + +interface RouteDirectory { + readonly name: string; + readonly kind: RouteParameterKind | "literal"; +} + +const APP_PAGE_EXTENSION_PRIORITY = [ + ".mdx", + ".md", + ".tsx", + ".jsx", + ".ts", + ".js", +] as const; export async function getAppRouteEntity( projectDir: string, slug: string, adapter: RuntimeAdapter, appDirName = "app", + options: EntityResolutionOptions = {}, ): Promise { - const exactMatch = await tryExactMatch(projectDir, slug, adapter, appDirName); - if (exactMatch) return exactMatch; + const normalizedSlug = normalizeRouteSlug(slug); + const normalizedAppDir = normalizeProjectDirectory(appDirName); + if ( + !isBoundedPath(projectDir) || + normalizedSlug === null || + normalizedAppDir === null + ) return null; - return tryDynamicMatch(projectDir, slug, adapter, appDirName); + const appRoot = join(projectDir, normalizedAppDir); + if (!isBoundedPath(appRoot)) return null; + + return await withEntityResolutionAdmission( + projectDir, + adapter, + options, + async (session) => { + const exactMatch = await tryExactMatch( + appRoot, + normalizedSlug, + normalizedAppDir, + session, + ); + if (exactMatch) return exactMatch; + + return await tryDynamicMatch( + appRoot, + normalizedSlug, + normalizedAppDir, + session, + ); + }, + ); } async function tryExactMatch( - projectDir: string, + appRoot: string, slug: string, - adapter: RuntimeAdapter, - appDirName: string, + virtualRoot: string, + session: EntityResolutionSession, ): Promise { - const base = slug ? join(projectDir, appDirName, slug) : join(projectDir, appDirName); + const base = slug === "" ? appRoot : join(appRoot, slug); - if (adapter.fs.resolveFile) { + if (session.hasResolveFile) { for (const basePath of [`${base}/page`, base]) { - const resolvedPath = await adapter.fs.resolveFile(basePath); - if (!resolvedPath) continue; + const resolvedPath = await session.resolveFile(basePath); + if (resolvedPath === null) continue; - const entity = await tryLoadPageFile(resolvedPath, slug, adapter); + const entity = await loadAppPage( + resolvedPath, + slug, + appRoot, + virtualRoot, + session, + ); if (entity) return entity; } return null; } - const candidates = [ - `${base}/page.mdx`, - `${base}/page.md`, - `${base}/page.tsx`, - `${base}/page.jsx`, - `${base}/page.ts`, - `${base}/page.js`, - `${base}.mdx`, - `${base}.md`, - `${base}.tsx`, - `${base}.jsx`, - `${base}.ts`, - `${base}.js`, - ]; - - for (const file of candidates) { - const entity = await tryLoadPageFile(file, slug, adapter); + for (const extension of APP_PAGE_EXTENSION_PRIORITY) { + const entity = await loadAppPage( + `${base}/page${extension}`, + slug, + appRoot, + virtualRoot, + session, + ); + if (entity) return entity; + } + for (const extension of APP_PAGE_EXTENSION_PRIORITY) { + const entity = await loadAppPage( + `${base}${extension}`, + slug, + appRoot, + virtualRoot, + session, + ); if (entity) return entity; } @@ -68,102 +127,189 @@ async function tryExactMatch( } async function tryDynamicMatch( - projectDir: string, + appRoot: string, slug: string, - adapter: RuntimeAdapter, - appDirName: string, + virtualRoot: string, + session: EntityResolutionSession, ): Promise { - const segments = slug ? slug.split("/").filter(Boolean) : []; - let currentDir = join(projectDir, appDirName); + const segments = slug === "" ? [] : slug.split("/"); + let currentDir = appRoot; - for (const segment of segments) { - const routeDirectory = await findRouteDirectory(currentDir, segment, adapter); + for (let index = 0; index < segments.length; index++) { + const routeDirectory = await findRouteDirectory( + currentDir, + segments[index]!, + session, + ); if (!routeDirectory) return null; currentDir = join(currentDir, routeDirectory.name); - if (routeDirectory.isCatchAll) break; + if ( + routeDirectory.kind === "catch-all" || + routeDirectory.kind === "optional-catch-all" + ) break; } - for (const ext of [".mdx", ".md", ".tsx", ".jsx", ".ts", ".js"]) { - const pageFile = join(currentDir, `page${ext}`); - const entity = await tryLoadPageFile(pageFile, slug, adapter); - if (entity) return entity; + const directPage = await loadPageFromDirectory( + currentDir, + slug, + appRoot, + virtualRoot, + session, + ); + if (directPage) return directPage; + + const optionalDirectory = await findOptionalCatchAllDirectory(currentDir, session); + if (optionalDirectory) { + return await loadPageFromDirectory( + join(currentDir, optionalDirectory), + slug, + appRoot, + virtualRoot, + session, + ); } return null; } async function findRouteDirectory( - dir: string, + directory: string, segment: string, - adapter: RuntimeAdapter, -): Promise<{ name: string; isCatchAll: boolean } | null> { - try { - const entries = await adapter.fs.readDir(dir); - let dynamic: { name: string; isCatchAll: boolean } | null = null; - - for await (const entry of entries) { - if (!entry.isDirectory && !entry.isSymlink) continue; - if (entry.name === segment) return { name: entry.name, isCatchAll: false }; - if (!dynamic && isDynamicSegment(entry.name)) { - dynamic = { - name: entry.name, - isCatchAll: isCatchAllSegment(entry.name), - }; - } - } + session: EntityResolutionSession, +): Promise { + const entries = await readDirectoryOrNull(directory, session); + if (!entries) return null; + + const literal = entries.find((entry) => entry.isDirectory && entry.name === segment); + if (literal) return { name: literal.name, kind: "literal" }; - return dynamic; - } catch (_) { - /* expected: adapter.fs.readDir may fail for npm compatibility */ + for (const kind of ["dynamic", "catch-all", "optional-catch-all"] as const) { + const candidates = entries + .filter((entry) => entry.isDirectory) + .map((entry) => ({ + entry, + parameter: parseRouteParameterSegment(entry.name), + })) + .filter((candidate) => + candidate.parameter?.kind === kind && candidate.parameter.suffix === "" + ); + if (candidates.length > 1) { + throw ROUTE_CONFLICT.create({ + detail: `Multiple ${kind} App Router directories match the same route segment`, + context: { candidateCount: candidates.length }, + }); + } + const candidate = candidates[0]; + if (candidate) return { name: candidate.entry.name, kind }; } return null; } -async function tryLoadPageFile( - file: string, - slug: string, - adapter: RuntimeAdapter, -): Promise { - let raw: string; +async function findOptionalCatchAllDirectory( + directory: string, + session: EntityResolutionSession, +): Promise { + const entries = await readDirectoryOrNull(directory, session); + if (!entries) return null; + const candidates = entries.filter((entry) => { + if (!entry.isDirectory) return false; + const parameter = parseRouteParameterSegment(entry.name); + return parameter?.kind === "optional-catch-all" && parameter.suffix === ""; + }); + if (candidates.length > 1) { + throw ROUTE_CONFLICT.create({ + detail: "Multiple optional catch-all App Router directories match the same route", + context: { candidateCount: candidates.length }, + }); + } + return candidates[0]?.name ?? null; +} + +async function readDirectoryOrNull( + directory: string, + session: EntityResolutionSession, +): Promise> | null> { try { - raw = await adapter.fs.readFile(file); - } catch (_) { - /* expected: file may not be readable */ - return null; + return await session.readDirectory(directory); + } catch (error) { + if (isCanonicalNotFoundError(error)) return null; + throw error; } +} - let content = raw; - let fm: Record = {}; - - if (raw.trim().startsWith("---")) { - try { - const ex = extract(raw); - content = ex.body; - fm = (ex.attrs as Record) ?? {}; - } catch (_) { - /* expected: malformed frontmatter - use raw content as-is */ - content = raw; - } +async function loadPageFromDirectory( + directory: string, + slug: string, + appRoot: string, + virtualRoot: string, + session: EntityResolutionSession, +): Promise { + for (const extension of APP_PAGE_EXTENSION_PRIORITY) { + const entity = await loadAppPage( + join(directory, `page${extension}`), + slug, + appRoot, + virtualRoot, + session, + ); + if (entity) return entity; } + return null; +} + +async function loadAppPage( + filePath: string, + slug: string, + appRoot: string, + virtualRoot: string, + session: EntityResolutionSession, +): Promise { + const info = await session.readEntityWithinRoot( + filePath, + appRoot, + virtualRoot, + ); + if (!info) return null; - const frontmatter: Record = { ...fm }; + const frontmatter: Frontmatter = { ...info.entity.frontmatter }; if (typeof frontmatter.layout === "boolean") { frontmatter.layout = frontmatter.layout ? "default" : "false"; } return { + ...info, entity: { - id: file, - path: file, + ...info.entity, slug, type: "page", isPage: true, isLayout: false, isComponent: false, - content, - frontmatter: frontmatter as Frontmatter, + frontmatter, }, }; } + +function normalizeRouteSlug(value: string): string | null { + if (!isBoundedPath(value) || value.includes("\\")) return null; + const segments = value.split("/").filter((segment) => segment !== "" && segment !== "."); + if ( + segments.length > MAX_ROUTE_SEGMENTS || + segments.some((segment) => segment === "..") + ) return null; + return segments.join("/"); +} + +function normalizeProjectDirectory(value: string): string | null { + if (!isBoundedPath(value) || value.includes("\\") || isAbsolute(value)) return null; + const segments = value.split("/").filter((segment) => segment !== "" && segment !== "."); + if (segments.some((segment) => segment === "..")) return null; + return segments.length === 0 ? "." : segments.join("/"); +} + +function isBoundedPath(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_PATH_LENGTH_CHARS && + !containsPathControlCharacters(value); +} diff --git a/src/rendering/orchestrator/pipeline.ts b/src/rendering/orchestrator/pipeline.ts index 370cc2bad1..aef998165e 100644 --- a/src/rendering/orchestrator/pipeline.ts +++ b/src/rendering/orchestrator/pipeline.ts @@ -701,7 +701,10 @@ export class RenderPipeline { () => withSpan( "render.resolve_page", - () => this.config.pageResolver.resolvePage(slug), + () => + this.config.pageResolver.resolvePage(slug, { + signal: options?.abortSignal, + }), { "render.slug": slug }, ), ); @@ -1027,7 +1030,10 @@ export class RenderPipeline { const pageInfo = await profilePhase( "page_data.resolve_page", - () => this.config.pageResolver.resolvePage(slug), + () => + this.config.pageResolver.resolvePage(slug, { + signal: options?.abortSignal, + }), ); const skipLayouts = isDotPath({ diff --git a/src/rendering/page-resolution/page-resolver.test.ts b/src/rendering/page-resolution/page-resolver.test.ts index c9ead53b15..ce49b8ec77 100644 --- a/src/rendering/page-resolution/page-resolver.test.ts +++ b/src/rendering/page-resolution/page-resolver.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { PageResolver } from "./page-resolver.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; @@ -11,13 +11,30 @@ interface DirEntry { isDirectory: boolean; } +function fileNotFoundError(): Error { + return Object.assign(new Error("File not found"), { code: "ENOENT" }); +} + +function virtualTextRead(readFile: (path: string) => Promise) { + return { + symlinkSemantics: "none" as const, + readFile, + async readFileBytesWithinLimit(path: string, byteLimit: number): Promise { + const bytes = new TextEncoder().encode(await readFile(path)); + if (bytes.byteLength > byteLimit) throw new RangeError("File exceeds byte limit"); + return bytes; + }, + }; +} + function createMockAdapter( dirEntries: Record = {}, existingDirs: string[] = [], ): RuntimeAdapter { return { + id: "memory", fs: { - readFile: async () => "", + ...virtualTextRead(async () => ""), exists: async (path: string) => existingDirs.includes(path), readDir: async function* (path: string) { const entries = dirEntries[path] ?? []; @@ -71,12 +88,13 @@ describe("rendering/page-resolution/page-resolver", () => { ["/project/src/content/[slug].tsx", "export default function Page() { return null; }"], ]); const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { const source = files.get(path); - if (source === undefined) throw new Error("File not found"); + if (source === undefined) throw fileNotFoundError(); return source; - }, + }), resolveFile: async (path: string) => { if (path === "/project/src/content/index") { return "/project/src/content/index.tsx"; @@ -90,7 +108,7 @@ describe("rendering/page-resolution/page-resolver", () => { if (path === "/project/src/content") { return { isFile: false, isDirectory: true, isSymlink: false }; } - throw new Error("File not found"); + throw fileNotFoundError(); }, exists: async (path: string) => path === "/project/src/content", readDir: async function* (path: string) { @@ -125,17 +143,18 @@ describe("rendering/page-resolution/page-resolver", () => { it("resolves dynamic App Router pages without remote stat misses", async () => { let statCalls = 0; const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { if (path === "/project/app/[slug]/page.tsx") { return "export default function Page() { return null; }"; } - throw new Error("File not found"); - }, + throw fileNotFoundError(); + }), resolveFile: async () => null, stat: async () => { statCalls++; - throw new Error("File not found"); + throw fileNotFoundError(); }, readDir: async function* (path: string) { if (path === "/project/app") { @@ -159,15 +178,107 @@ describe("rendering/page-resolution/page-resolver", () => { assertEquals(statCalls, 0); }); + it("propagates an App Router resolution deadline through active adapter work", async () => { + let releaseResolveFile!: (path: string | null) => void; + const blockedResolveFile = new Promise((resolve) => { + releaseResolveFile = resolve; + }); + const adapter = { + id: "memory", + fs: { + ...virtualTextRead(() => Promise.resolve("# Page")), + resolveFile: () => blockedResolveFile, + readDir: async function* () {}, + writeFile: async () => {}, + mkdir: async () => {}, + }, + env: { get: () => undefined }, + } as unknown as RuntimeAdapter; + const resolver = new PageResolver({ + projectDir: "/project", + projectId: "app-timeout-project", + config: createMockConfig({ router: "app" }), + adapter, + }); + + await assertRejects( + () => + resolver.resolvePage("", { + deadline: Date.now() + 50, + }), + Error, + "deadline", + ); + + releaseResolveFile(null); + await Promise.resolve(); + }); + + it("propagates a resolution deadline while auto router detection is active", async () => { + let releaseDirectoryReads!: () => void; + const directoryReadsReleased = new Promise((resolve) => { + releaseDirectoryReads = resolve; + }); + const adapter = { + id: "memory", + fs: { + ...virtualTextRead(() => Promise.resolve("# Page")), + resolveFile: async () => null, + readDir: () => ({ + [Symbol.asyncIterator]() { + return { + async next() { + await directoryReadsReleased; + return { done: true as const, value: undefined }; + }, + }; + }, + }), + writeFile: async () => {}, + mkdir: async () => {}, + }, + env: { get: () => undefined }, + } as unknown as RuntimeAdapter; + const resolver = new PageResolver({ + projectDir: "/project", + projectId: "auto-timeout-project", + config: createMockConfig(), + adapter, + }); + + const request = resolver.resolvePage("article", { + deadline: Date.now() + 50, + }); + let pendingTimer: ReturnType | undefined; + const outcome = await Promise.race([ + request.then( + () => "resolved" as const, + (error) => + error instanceof Error && error.message.includes("deadline") + ? "deadline" as const + : "other-error" as const, + ), + new Promise<"still-pending">((resolve) => { + pendingTimer = setTimeout(() => resolve("still-pending"), 200); + }), + ]); + if (pendingTimer !== undefined) clearTimeout(pendingTimer); + releaseDirectoryReads(); + await request.catch(() => undefined); + + assertEquals(outcome, "deadline"); + }); + it("resolves optional catch-all App Router pages across remaining segments", async () => { const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { if (path === "/project/app/[[...slug]]/page.tsx") { return "export default function Page() { return null; }"; } - throw new Error("File not found"); - }, + throw fileNotFoundError(); + }), resolveFile: async () => null, readDir: async function* (path: string) { if (path === "/project/app") { @@ -192,13 +303,14 @@ describe("rendering/page-resolution/page-resolver", () => { it("keeps auto router detection aligned with the structural app router", async () => { const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { if (path === "/project/app/page.tsx") { return "export default function Page() { return null; }"; } - throw new Error("File not found"); - }, + throw fileNotFoundError(); + }), resolveFile: async (path: string) => { if (path === "/project/app/page") { return "/project/app/page.tsx"; @@ -213,7 +325,7 @@ describe("rendering/page-resolution/page-resolver", () => { isSymlink: false, }; } - throw new Error("File not found"); + throw fileNotFoundError(); }, exists: async (path: string) => path === "/project/app", readDir: async function* (path: string) { @@ -246,14 +358,15 @@ describe("rendering/page-resolution/page-resolver", () => { const resolveCalls: string[] = []; const readCalls: string[] = []; const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { readCalls.push(path); if (path === "/project/pages/index.tsx") { return "export default function Page() { return null; }"; } - throw new Error("File not found"); - }, + throw fileNotFoundError(); + }), resolveFile: async (path: string) => { resolveCalls.push(path); if (path === "/project/app/page") { @@ -279,7 +392,7 @@ describe("rendering/page-resolution/page-resolver", () => { isSymlink: false, }; } - throw new Error("File not found"); + throw fileNotFoundError(); }, exists: async (path: string) => path === "/project/pages", readDir: async function* (path: string) { @@ -314,13 +427,14 @@ describe("rendering/page-resolution/page-resolver", () => { it("does not poison auto router detection from a pages fallback", async () => { const adapter = { + id: "memory", fs: { - readFile: async (path: string) => { + ...virtualTextRead(async (path: string) => { if (path === "/project/pages/index.tsx") { return "export default function Page() { return null; }"; } - throw new Error("File not found"); - }, + throw fileNotFoundError(); + }), resolveFile: async (path: string) => { if (path === "/project/app/page") { return null; @@ -345,7 +459,7 @@ describe("rendering/page-resolution/page-resolver", () => { isSymlink: false, }; } - throw new Error("File not found"); + throw fileNotFoundError(); }, exists: async (path: string) => path === "/project/app" || path === "/project/pages", readDir: async function* (path: string) { diff --git a/src/rendering/page-resolution/page-resolver.ts b/src/rendering/page-resolution/page-resolver.ts index 5f7a453fc4..85e8e1af68 100644 --- a/src/rendering/page-resolution/page-resolver.ts +++ b/src/rendering/page-resolution/page-resolver.ts @@ -5,7 +5,11 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { VeryfrontConfig } from "#veryfront/config"; import type { EntityInfo } from "#veryfront/types"; -import { getEntityBySlug } from "#veryfront/types/entities/getEntityInfo.ts"; +import { + type EntityResolutionOptions, + getEntityBySlug, + withEntityResolutionAdmission, +} from "#veryfront/types/entities/getEntityInfo.ts"; import { detectAppRouter, getAppRouteEntity, @@ -40,6 +44,11 @@ function appDirToSlug(dirPath: string, appDirName: string): string { return relativePath === "" ? "/" : `/${relativePath}`; } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + throw signal.reason ?? new DOMException("Page resolution was aborted", "AbortError"); +} + export interface PageResolverOptions { projectDir: string; projectId?: string; @@ -60,10 +69,18 @@ export class PageResolver { this.adapter = options.adapter; } - resolvePage(slug: string): Promise { + resolvePage( + slug: string, + options: EntityResolutionOptions = {}, + ): Promise { + const resolutionOptions: EntityResolutionOptions = { + ...options, + scopeKey: options.scopeKey ?? this.projectId ?? this.projectDir, + }; return withSpan( "routing.resolve_page", async () => { + throwIfAborted(resolutionOptions.signal); const appDirName = this.config.directories?.app ?? "app"; const pagesDirName = this.config.directories?.pages ?? "pages"; const cacheKey = this.projectId ?? this.projectDir; @@ -76,6 +93,7 @@ export class PageResolver { slug, this.adapter, appDirName, + resolutionOptions, ); if (pageInfo) { primeRouterDetectionCache(cacheKey, "app"); @@ -86,6 +104,7 @@ export class PageResolver { slug, this.adapter, pagesDirName, + resolutionOptions, ); if (pageInfo) { primeRouterDetectionCache(cacheKey, "pages"); @@ -93,11 +112,19 @@ export class PageResolver { } else { // Auto mode stays structural: detect the dominant router once, then keep // pages fallback available for mixed or in-transition projects. - const useAppRouter = await detectAppRouter( + const useAppRouter = await withEntityResolutionAdmission( this.projectDir, - this.config, this.adapter, - { projectId: this.projectId }, + resolutionOptions, + (session) => + session.awaitOperation(() => + detectAppRouter( + this.projectDir, + this.config, + this.adapter, + { projectId: this.projectId }, + ) + ), ); if (useAppRouter) { @@ -106,6 +133,7 @@ export class PageResolver { slug, this.adapter, appDirName, + resolutionOptions, ); if (!pageInfo) { pageInfo = await getEntityBySlug( @@ -113,6 +141,7 @@ export class PageResolver { slug, this.adapter, pagesDirName, + resolutionOptions, ); } } else { @@ -121,10 +150,13 @@ export class PageResolver { slug, this.adapter, pagesDirName, + resolutionOptions, ); } } + throwIfAborted(resolutionOptions.signal); + if (!pageInfo) { throw FILE_NOT_FOUND.create({ detail: `Page not found: ${slug}`, @@ -229,9 +261,12 @@ export class PageResolver { } } - async pageExists(slug: string): Promise { + async pageExists( + slug: string, + options: EntityResolutionOptions = {}, + ): Promise { try { - await this.resolvePage(slug); + await this.resolvePage(slug, options); return true; } catch (error: unknown) { if (error instanceof VeryfrontError && error.slug === "file-not-found") { diff --git a/src/security/http/outbound-fetch.test.ts b/src/security/http/outbound-fetch.test.ts index 5b98b6d8ae..12af73dfa5 100644 --- a/src/security/http/outbound-fetch.test.ts +++ b/src/security/http/outbound-fetch.test.ts @@ -1,10 +1,15 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { guardedEgressFetch, WorkerEgressBlockedError, } from "#veryfront/security/sandbox/worker-egress-guard.ts"; -import { createOutboundFetchBoundary, OutboundRequestBlockedError } from "./outbound-fetch.ts"; +import { + createOutboundFetchBoundary, + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "./outbound-fetch.ts"; function createTestBoundary(fetchImpl: typeof fetch) { return createOutboundFetchBoundary({ @@ -14,6 +19,22 @@ function createTestBoundary(fetchImpl: typeof fetch) { } describe("guardedOutboundFetch", () => { + it("uses withMockFetch transport instead of the captured host fetch in tests", async () => { + let captured: Request | undefined; + + const response = await withMockFetch( + async (input: RequestInfo | URL, init?: RequestInit) => { + captured = new Request(input, init); + return Response.json({ mocked: true }); + }, + () => guardedOutboundFetch("https://93.184.216.34/rag/documents"), + ); + + assertEquals(response.status, 200); + assertEquals(await response.json(), { mocked: true }); + assertEquals(captured?.url, "https://93.184.216.34/rag/documents"); + }); + it("rejects loopback and cloud metadata before invoking fetch", async () => { let calls = 0; const fetchImpl: typeof fetch = () => { diff --git a/src/security/http/outbound-fetch.ts b/src/security/http/outbound-fetch.ts index 1001dc11a1..a91e50318a 100644 --- a/src/security/http/outbound-fetch.ts +++ b/src/security/http/outbound-fetch.ts @@ -44,8 +44,13 @@ export interface OutboundFetchBoundary { // Capture the host transport before tenant code can replace globalThis.fetch. const capturedHostFetch = globalThis.fetch.bind(globalThis); +let outboundFetchTransportForTests: Readonly | undefined; function getTrustedHostTransport(): OutboundFetchTransport { + if (outboundFetchTransportForTests !== undefined) { + return outboundFetchTransportForTests; + } + if (getHostEnv("DENO_TESTING") !== "1") { // Omitting pinnedFetch is deliberate: Node and Bun then use the native // address-pinned transport, while Deno uses its pinned SOCKS client. @@ -185,6 +190,19 @@ export function createOutboundFetchBoundary( }); } +export async function __runWithOutboundFetchTransportForTests( + transport: OutboundFetchTransport, + fn: () => Promise, +): Promise { + const previous = outboundFetchTransportForTests; + outboundFetchTransportForTests = snapshotOutboundFetchTransport(transport); + try { + return await fn(); + } finally { + outboundFetchTransportForTests = previous; + } +} + /** * Fetch an HTTP resource through the host egress ceiling. * diff --git a/src/security/secure-fs.test.ts b/src/security/secure-fs.test.ts index 5c7a4667a9..b58495bf22 100644 --- a/src/security/secure-fs.test.ts +++ b/src/security/secure-fs.test.ts @@ -12,6 +12,7 @@ import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.t import type { RuntimeAdapter, ServeOptions, Server } from "#veryfront/platform/adapters/base.ts"; import { captureBoundedTextReader } from "#veryfront/platform/adapters/bounded-text-reader.ts"; import { FileSnapshotChangedError } from "#veryfront/platform/adapters/file-snapshot-error.ts"; +import { NodeCompatibleFileSystemAdapter } from "#veryfront/platform/adapters/runtime/shared/node-filesystem-adapter.ts"; function createMockFileSystem( overrides: Partial = {}, @@ -828,6 +829,30 @@ describe("SecureFs", () => { assertEquals({ originalCalls, replacementCalls }, { originalCalls: 1, replacementCalls: 0 }); }); + it("reads a canonical snapshot beneath a symlinked configured root", async () => { + if (Deno.build.os === "windows") return; + const workspace = await Deno.makeTempDir({ prefix: "veryfront-secure-fs-snapshot-root-" }); + const physicalRoot = `${workspace}/physical`; + const linkedRoot = `${workspace}/linked`; + try { + await Deno.mkdir(physicalRoot); + await Deno.writeFile(`${physicalRoot}/asset.bin`, new Uint8Array([1, 2, 3])); + await Deno.symlink(physicalRoot, linkedRoot); + const secureFs = createSecureFs({ + baseDir: linkedRoot, + adapter: { fs: new NodeCompatibleFileSystemAdapter() } as unknown as RuntimeAdapter, + context: "build", + }); + + assertEquals( + [...await secureFs.readFileSnapshotWithinLimit!("asset.bin", 3)], + [1, 2, 3], + ); + } finally { + await Deno.remove(workspace, { recursive: true }); + } + }); + it("rejects traversal before invoking raw snapshot authority", async () => { let reads = 0; const secureFs = createSecureFs({ diff --git a/src/server/handlers/request/api/api-handler-wrapper.test.ts b/src/server/handlers/request/api/api-handler-wrapper.test.ts index fd673d3278..9a138a9804 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.test.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { HandlerContext } from "#veryfront/types"; import { ApiHandlerWrapper } from "./api-handler-wrapper.ts"; @@ -41,6 +41,31 @@ function createCtx(captured: { options?: Record }): HandlerCont } describe("ApiHandlerWrapper", () => { + it("propagates request cancellation instead of continuing handler discovery", async () => { + const ctx = createCtx({}); + const fs = ctx.adapter.fs as unknown as { + runWithContext: ( + slug: string, + token: string, + fn: () => Promise, + ) => Promise; + }; + fs.runWithContext = async (_slug, _token, fn) => await fn(); + const handler = new ApiHandlerWrapper("/tmp/project", ctx.adapter); + const controller = new AbortController(); + controller.abort(new Error("request cancelled")); + + await assertRejects( + () => + handler.handle( + new Request("http://localhost/review", { signal: controller.signal }), + ctx, + ), + Error, + "request cancelled", + ); + }); + it("routes known pages without remote stat misses or full API discovery", async () => { let enteredProjectContext = false; let remoteStatMisses = 0; @@ -64,7 +89,9 @@ describe("ApiHandlerWrapper", () => { isSymlink: boolean; }>; resolveFile: (path: string) => Promise; + symlinkSemantics: "none"; readFile: (path: string) => Promise; + readFileBytesWithinLimit: (path: string, byteLimit: number) => Promise; refreshSourceSnapshot: (reason?: string) => Promise; }; fs.runWithContext = async (_slug, _token, fn) => { @@ -95,13 +122,26 @@ describe("ApiHandlerWrapper", () => { Promise.resolve( path === "/tmp/project/pages/review" ? "/tmp/project/pages/review.tsx" : null, ); + fs.symlinkSemantics = "none"; fs.readFile = (path) => { if (path === "/tmp/project/pages/review.tsx" || path === "pages/review.tsx") { - pageReads++; return Promise.resolve("export default function Review() { return null; }"); } return Promise.reject(new Error("File not found")); }; + fs.readFileBytesWithinLimit = (path, byteLimit) => { + if (path === "/tmp/project/pages/review.tsx" || path === "pages/review.tsx") { + pageReads++; + const source = new TextEncoder().encode( + "export default function Review() { return null; }", + ); + if (source.byteLength > byteLimit) { + return Promise.reject(new Error("File exceeds byte limit")); + } + return Promise.resolve(source); + } + return Promise.reject(new Error("File not found")); + }; fs.refreshSourceSnapshot = () => { sourceSnapshotRefreshes++; return Promise.resolve(); @@ -135,7 +175,9 @@ describe("ApiHandlerWrapper", () => { exists: (path: string) => Promise; readDir: (path: string) => AsyncIterable; resolveFile: (path: string) => Promise; + symlinkSemantics: "none"; readFile: (path: string) => Promise; + readFileBytesWithinLimit: (path: string, byteLimit: number) => Promise; refreshSourceSnapshot: (reason?: string) => Promise; }; fs.runWithContext = async (_slug, _token, fn) => await fn(); @@ -151,12 +193,25 @@ describe("ApiHandlerWrapper", () => { path === "/tmp/project/pages/review" ? "/tmp/project/pages/review.tsx" : null, ); }; + fs.symlinkSemantics = "none"; fs.readFile = (path) => { if (path === "pages/review.tsx" || path === "/tmp/project/pages/review.tsx") { return Promise.resolve("export default function Review() { return null; }"); } return Promise.reject(new Error("File not found")); }; + fs.readFileBytesWithinLimit = (path, byteLimit) => { + if (path === "pages/review.tsx" || path === "/tmp/project/pages/review.tsx") { + const source = new TextEncoder().encode( + "export default function Review() { return null; }", + ); + if (source.byteLength > byteLimit) { + return Promise.reject(new Error("File exceeds byte limit")); + } + return Promise.resolve(source); + } + return Promise.reject(new Error("File not found")); + }; fs.refreshSourceSnapshot = () => { events.push("full-refresh"); return Promise.resolve(); diff --git a/src/server/handlers/request/api/api-handler-wrapper.ts b/src/server/handlers/request/api/api-handler-wrapper.ts index 6e319d1971..d90cc50a72 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.ts @@ -147,7 +147,7 @@ export class ApiHandlerWrapper extends BaseHandler { let isPageRequest = false; if (canResolveAsPage) { - isPageRequest = await this.isPageRequest(pathname, ctx); + isPageRequest = await this.isPageRequest(pathname, ctx, req.signal); } if (isPageRequest) { @@ -192,6 +192,7 @@ export class ApiHandlerWrapper extends BaseHandler { return this.respond(finalRes); } catch (error) { + if (req.signal.aborted) throw error; this.logDebug( "[API-Wrapper] API handler error - falling through to next handler", { @@ -235,7 +236,11 @@ export class ApiHandlerWrapper extends BaseHandler { return this.respond(response, { executionTopology: "dedicated-runtime-required" }); } - private async isPageRequest(pathname: string, ctx: HandlerContext): Promise { + private async isPageRequest( + pathname: string, + ctx: HandlerContext, + signal?: AbortSignal, + ): Promise { const slug = pathname === "/" ? "" : pathname.replace(/^\/+|\/+$/g, ""); const pageResolver = new PageResolver({ projectDir: ctx.projectDir, @@ -245,8 +250,9 @@ export class ApiHandlerWrapper extends BaseHandler { }); try { - return await pageResolver.pageExists(slug); + return await pageResolver.pageExists(slug, { signal }); } catch (error) { + if (signal?.aborted) throw error; this.logDebug( "[API-Wrapper] Page ownership is indeterminate; preserving API discovery", { diff --git a/src/server/handlers/request/api/app-router-resolver.test.ts b/src/server/handlers/request/api/app-router-resolver.test.ts index 7de27b6b0a..05702dbc6c 100644 --- a/src/server/handlers/request/api/app-router-resolver.test.ts +++ b/src/server/handlers/request/api/app-router-resolver.test.ts @@ -167,6 +167,57 @@ describe("resolveAppRouteFile", () => { }); }); + it("keeps special parameter names as own keys on a null-prototype record", async () => { + const ctx = createMockCtx({ + statMap: { + "/project/app": { isFile: false, isDirectory: true }, + "/project/app/api/[__proto__]/[toString]/route.ts": { + isFile: true, + isDirectory: false, + }, + }, + dirMap: { + "/project/app": [dir("api")], + "/project/app/api": [dir("[__proto__]")], + "/project/app/api/[__proto__]": [dir("[toString]")], + "/project/app/api/[__proto__]/[toString]": [], + }, + }); + + const result = await resolveAppRouteFile("/api/prototype/value", ctx); + + assertEquals(result?.file, "/project/app/api/[__proto__]/[toString]/route.ts"); + assertEquals(Object.getPrototypeOf(result?.params), null); + assertEquals(Object.hasOwn(result?.params ?? {}, "__proto__"), true); + assertEquals(Object.hasOwn(result?.params ?? {}, "toString"), true); + assertEquals(result?.params["__proto__"], "prototype"); + assertEquals(Object.getOwnPropertyDescriptor(result?.params ?? {}, "toString")?.value, "value"); + }); + + it("matches a dotted dynamic name through the canonical route parser", async () => { + const ctx = createMockCtx({ + statMap: { + "/project/app": { isFile: false, isDirectory: true }, + "/project/app/api/[version.number]/route.ts": { + isFile: true, + isDirectory: false, + }, + }, + dirMap: { + "/project/app": [dir("api")], + "/project/app/api": [dir("[version.number]")], + "/project/app/api/[version.number]": [], + }, + }); + + const result = await resolveAppRouteFile("/api/v2", ctx); + + assertEquals(result, { + file: "/project/app/api/[version.number]/route.ts", + params: { "version.number": "v2" }, + }); + }); + it("matches catch-all [...slug] for multi-segment paths", async () => { const ctx = createMockCtx({ statMap: { @@ -187,6 +238,31 @@ describe("resolveAppRouteFile", () => { }); }); + it("extracts a dotted catch-all name through the canonical route parser", async () => { + const ctx = createMockCtx({ + statMap: { + "/project/app": { isFile: false, isDirectory: true }, + "/project/app/api/docs/[...path.parts]/route.ts": { + isFile: true, + isDirectory: false, + }, + }, + dirMap: { + "/project/app": [dir("api")], + "/project/app/api": [dir("docs")], + "/project/app/api/docs": [dir("[...path.parts]")], + "/project/app/api/docs/[...path.parts]": [], + }, + }); + + const result = await resolveAppRouteFile("/api/docs/a/b", ctx); + + assertEquals(result, { + file: "/project/app/api/docs/[...path.parts]/route.ts", + params: { "path.parts": ["a", "b"] }, + }); + }); + it("matches optional catch-all [[...slug]]", async () => { const ctx = createMockCtx({ statMap: { @@ -227,6 +303,80 @@ describe("resolveAppRouteFile", () => { }); }); + it("extracts a dotted optional catch-all name with no segments", async () => { + const ctx = createMockCtx({ + statMap: { + "/project/app": { isFile: false, isDirectory: true }, + "/project/app/api/search/[[...query.parts]]/route.ts": { + isFile: true, + isDirectory: false, + }, + }, + dirMap: { + "/project/app": [dir("api")], + "/project/app/api": [dir("search")], + "/project/app/api/search": [dir("[[...query.parts]]")], + "/project/app/api/search/[[...query.parts]]": [], + }, + }); + + const result = await resolveAppRouteFile("/api/search", ctx); + + assertEquals(result, { + file: "/project/app/api/search/[[...query.parts]]/route.ts", + params: { "query.parts": [] }, + }); + }); + + it("does not classify invalid parameter directories as routes", async () => { + const invalidDirectories = [ + "[bad name]", + "[.slug]", + "[slug..part]", + "[id].tsx", + ]; + const statMap: Record = { + "/project/app": { isFile: false, isDirectory: true }, + }; + const dirMap: Record = { + "/project/app": [dir("api")], + "/project/app/api": invalidDirectories.map(dir), + }; + for (const directory of invalidDirectories) { + statMap[`/project/app/api/${directory}/route.ts`] = { + isFile: true, + isDirectory: false, + }; + dirMap[`/project/app/api/${directory}`] = []; + } + const ctx = createMockCtx({ statMap, dirMap }); + + assertEquals(await resolveAppRouteFile("/api/value", ctx), null); + }); + + it("resolves hyphenated parameter directories", async () => { + const ctx = createMockCtx({ + statMap: { + "/project/app": { isFile: false, isDirectory: true }, + "/project/app/api/posts/[post-id]/route.ts": { + isFile: true, + isDirectory: false, + }, + }, + dirMap: { + "/project/app": [dir("api")], + "/project/app/api": [dir("posts")], + "/project/app/api/posts": [dir("[post-id]")], + "/project/app/api/posts/[post-id]": [], + }, + }); + + assertEquals(await resolveAppRouteFile("/api/posts/123", ctx), { + file: "/project/app/api/posts/[post-id]/route.ts", + params: { "post-id": "123" }, + }); + }); + it("falls back to catch-all when a dynamic route cannot consume the full path", async () => { const ctx = createMockCtx({ statMap: { diff --git a/src/server/handlers/request/api/app-router-resolver.ts b/src/server/handlers/request/api/app-router-resolver.ts index bf7284013c..87335f934d 100644 --- a/src/server/handlers/request/api/app-router-resolver.ts +++ b/src/server/handlers/request/api/app-router-resolver.ts @@ -6,18 +6,46 @@ */ import { joinPath } from "#veryfront/utils/path-utils.ts"; -import { extractParamName } from "#veryfront/utils/route-path-utils.ts"; +import { + type ParsedRouteParameter, + parseRouteParameterSegment, +} from "#veryfront/utils/route-path-utils.ts"; import type { HandlerContext } from "../../types.ts"; import type { AppRouteMatch } from "./types.ts"; -const STANDARD_DYNAMIC_SEGMENT_RE = /^\[[^\]]+\]$/; -const CATCH_ALL_SEGMENT_RE = /^\[\.\.\.[^\]]+\]$/; -const OPTIONAL_CATCH_ALL_SEGMENT_RE = /^\[\[\.\.\.[^\]]+\]\]$/; +interface RouteParameterDirectory { + readonly directory: string; + readonly parameter: ParsedRouteParameter; +} + +type RouteParams = Record; + +function createRouteParams(): RouteParams { + return Object.create(null) as RouteParams; +} -function isStandardDynamicSegment(name: string): boolean { - return STANDARD_DYNAMIC_SEGMENT_RE.test(name) && - !CATCH_ALL_SEGMENT_RE.test(name) && - !OPTIONAL_CATCH_ALL_SEGMENT_RE.test(name); +function withRouteParam( + params: RouteParams, + name: string, + value: string | string[], +): RouteParams { + const next = Object.assign(createRouteParams(), params); + next[name] = value; + return next; +} + +function parameterDirectories( + names: readonly string[], + kind: ParsedRouteParameter["kind"], +): RouteParameterDirectory[] { + const matches: RouteParameterDirectory[] = []; + for (const directory of names) { + const parameter = parseRouteParameterSegment(directory); + if (parameter?.kind === kind && parameter.suffix === "") { + matches.push({ directory, parameter }); + } + } + return matches; } async function readDirectoryNames(current: string, ctx: HandlerContext): Promise { @@ -55,7 +83,7 @@ async function resolveFromDirectory( current: string, segments: string[], index: number, - params: Record, + params: RouteParams, ctx: HandlerContext, ): Promise { if (index >= segments.length) { @@ -65,17 +93,15 @@ async function resolveFromDirectory( const names = await readDirectoryNames(current, ctx); if (!names) return null; - for ( - const optionalCatchAll of names.filter((name) => OPTIONAL_CATCH_ALL_SEGMENT_RE.test(name)) - ) { - const optionalFile = await findRouteFile(joinPath(current, optionalCatchAll), ctx); + for (const optionalCatchAll of parameterDirectories(names, "optional-catch-all")) { + const optionalFile = await findRouteFile( + joinPath(current, optionalCatchAll.directory), + ctx, + ); if (optionalFile) { return { file: optionalFile, - params: { - ...params, - [extractParamName(optionalCatchAll)]: [], - }, + params: withRouteParam(params, optionalCatchAll.parameter.name, []), }; } } @@ -99,15 +125,12 @@ async function resolveFromDirectory( if (exactMatch) return exactMatch; } - for (const dynamicSegment of names.filter(isStandardDynamicSegment)) { + for (const dynamicSegment of parameterDirectories(names, "dynamic")) { const dynamicMatch = await resolveFromDirectory( - joinPath(current, dynamicSegment), + joinPath(current, dynamicSegment.directory), segments, index + 1, - { - ...params, - [extractParamName(dynamicSegment)]: seg, - }, + withRouteParam(params, dynamicSegment.parameter.name, seg), ctx, ); if (dynamicMatch) return dynamicMatch; @@ -115,29 +138,23 @@ async function resolveFromDirectory( const remainingSegments = segments.slice(index); - for (const catchAllSegment of names.filter((name) => CATCH_ALL_SEGMENT_RE.test(name))) { + for (const catchAllSegment of parameterDirectories(names, "catch-all")) { const catchAllMatch = await resolveFromDirectory( - joinPath(current, catchAllSegment), + joinPath(current, catchAllSegment.directory), segments, segments.length, - { - ...params, - [extractParamName(catchAllSegment)]: remainingSegments, - }, + withRouteParam(params, catchAllSegment.parameter.name, remainingSegments), ctx, ); if (catchAllMatch) return catchAllMatch; } - for (const optionalCatchAll of names.filter((name) => OPTIONAL_CATCH_ALL_SEGMENT_RE.test(name))) { + for (const optionalCatchAll of parameterDirectories(names, "optional-catch-all")) { const optionalMatch = await resolveFromDirectory( - joinPath(current, optionalCatchAll), + joinPath(current, optionalCatchAll.directory), segments, segments.length, - { - ...params, - [extractParamName(optionalCatchAll)]: remainingSegments, - }, + withRouteParam(params, optionalCatchAll.parameter.name, remainingSegments), ctx, ); if (optionalMatch) return optionalMatch; @@ -162,5 +179,5 @@ export async function resolveAppRouteFile( const normalized = path === "/" ? "/" : path.replace(/\/$/, ""); const segments = normalized.split("/").filter(Boolean); - return resolveFromDirectory(appRoot, segments, 0, {}, ctx); + return resolveFromDirectory(appRoot, segments, 0, createRouteParams(), ctx); } diff --git a/src/server/services/rsc/endpoints/rsc-bundles.generated.ts b/src/server/services/rsc/endpoints/rsc-bundles.generated.ts index 2d25b377a5..1e819db99c 100644 --- a/src/server/services/rsc/endpoints/rsc-bundles.generated.ts +++ b/src/server/services/rsc/endpoints/rsc-bundles.generated.ts @@ -7,7 +7,7 @@ */ export const CLIENT_BOOT_BUNDLE: string = - 'var at=Object.defineProperty;var ct=(e,t,r)=>t in e?at(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var m=(e,t,r)=>ct(e,typeof t!="symbol"?t+"":t,r);var ut="3.2.3";function lt(e,t,r,n){let o=[];if(n?.external?.length&&o.push(`external=${n.external.join(",")}`),o.push(`target=${n?.target??"es2022"}`),n?.deps){let d=Object.entries(n.deps).map(([c,g])=>`${c}@${g}`).join(",");o.push(`deps=${d}`)}let s=t?`@${t}`:"",a=r??"",u=o.length?`?${o.join("&")}`:"";return`https://esm.sh/${e}${s}${a}${u}`}function _(e,t,r,n=!1){return lt(e,t,r,{external:n?["react"]:void 0,deps:{csstype:ut}})}var gt="19.2.4",O=gt;function Ee(e=O){return{react:_("react",e),"react-dom":_("react-dom",e,void 0,!0),"react-dom/client":_("react-dom",e,"/client",!0),"react-dom/server":_("react-dom",e,"/server",!0),"react/jsx-runtime":_("react",e,"/jsx-runtime",!0),"react/jsx-dev-runtime":_("react",e,"/jsx-dev-runtime",!0)}}function Re(e=O){return Ee(e).react}function he(e=O){return Ee(e)["react-dom/client"]}function ft(e){return e.replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}function pt(e){if(typeof globalThis.btoa=="function")try{return globalThis.btoa(e)}catch{return yt(new TextEncoder().encode(e))}let t=globalThis.Buffer;if(t)return t.from(e,"utf8").toString("base64");throw new Error("Base64 encoding is not supported in this runtime")}function yt(e){let t=globalThis.Buffer;if(t)return t.from(e).toString("base64");if(typeof globalThis.btoa=="function"){let r="";for(let n of e)r+=String.fromCharCode(n);return globalThis.btoa(r)}throw new Error("Base64 encoding is not supported in this runtime")}function te(e){return ft(pt(e))}var qo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function v(e,t){if(!t)return!1;if(Object.prototype.hasOwnProperty.call(t,e))return!0;for(let r of Object.keys(t))if(r.endsWith("/")&&e.startsWith(r))return!0;return!1}function mt(e){try{return JSON.parse(e)?.imports??{}}catch(t){return console.warn("Failed to parse import map JSON; treating as empty",{errorName:t instanceof Error?t.name:typeof t,inputLength:e.length}),{}}}function re(e=document){let t=e.querySelector(\'script[type="importmap"]\');return t?.textContent?mt(t.textContent):{}}var Zo=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Et=5e3,Rt=1e4,ti=16*1024*1024,ht=5e3;var _t=100;var xt=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),ri=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),ni=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Et,api:3e4,ssr:Rt,hmr:3e4,sandbox:ht}),cache:Object.freeze({jit:Object.freeze({maxSize:_t,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:xt})});var p="/_veryfront",ne={RSC:`${p}/rsc/`,FS:`${p}/fs/`,MODULES:`${p}/modules/`,PAGES:`${p}/pages/`,DATA:`${p}/data/`,LIB:`${p}/lib/`,CHUNKS:`${p}/chunks/`,CLIENT:`${p}/client/`},xe={HMR_RUNTIME:`${p}/hmr-runtime.js`,HMR:`${p}/hmr.js`,ERROR_OVERLAY:`${p}/error-overlay.js`,DEV_LOADER:`${p}/dev-loader.js`,CLIENT_LOG:`${p}/log`,CLIENT_JS:`${p}/client.js`,ROUTER_JS:`${p}/router.js`,PREFETCH_JS:`${p}/prefetch.js`,MANIFEST_JSON:`${p}/manifest.json`,APP_JS:`${p}/app.js`,RSC_CLIENT:`${p}/rsc/client.js`,RSC_MANIFEST:`${p}/rsc/manifest`,RSC_STREAM:`${p}/rsc/stream`,RSC_PAYLOAD:`${p}/rsc/payload`,RSC_RENDER:`${p}/rsc/render`,RSC_PAGE:`${p}/rsc/page`,RSC_MODULE:`${p}/rsc/module`,RSC_DOM:`${p}/rsc/dom.js`,LIB_CHAT_REACT:`${p}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${p}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${p}/lib/chat/primitives.js`};var Tt={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},ii=Tt.CACHE;var si={HMR_RUNTIME:xe.HMR_RUNTIME,ERROR_OVERLAY:xe.ERROR_OVERLAY};var I=ne.RSC,Te=ne.FS;var N="rsc-root",k="x-veryfront-dependency-pins";var T=class{constructor(t,r){m(this,"prefix",t);m(this,"level",r)}log(t,r,n,...o){this.level>t||r?.(n,...o)}debug(t,...r){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${t}`,...r)}info(t,...r){this.log(1,console.log,`[${this.prefix}] ${t}`,...r)}warn(t,...r){this.log(2,console.warn,`[${this.prefix}] WARN: ${t}`,...r)}error(t,...r){this.log(3,console.error,`[${this.prefix}] ERROR: ${t}`,...r)}};function St(){if(typeof window>"u")return 2;let e=globalThis;return e.__VERYFRONT_DEV__||e.__RSC_DEV__?e.__VERYFRONT_DEBUG__||e.__RSC_DEBUG__?0:1:2}var $=St(),l=new T("RSC",$),di=new T("PREFETCH",$),gi=new T("HYDRATE",$),fi=new T("VERYFRONT",$);var Ct="veryfront-hydration-data";function oe(e){try{let t=[...e.querySelectorAll(`[id="${Ct}"]`)];if(t.length!==1)return null;let r=e.body;if(!r)return null;let n=t[0];return r.firstElementChild!==n&&n.parentElement!==r||n.tagName?.toLowerCase()!=="script"||n.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:n}catch{return null}}function S(e=document){try{let t=oe(e);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function V(e,t){if(!t?.startsWith("on:"))return!1;try{let r=oe(e);if(!r)return!1;let n=JSON.parse(r.textContent||"{}");return n.dependencyPinningCacheKey=t,r.textContent=JSON.stringify(n),!0}catch(r){return l.debug("hydration dependency snapshot seed failed",r),!1}}function F(e){return e?.clientModuleStrategy?e.clientModuleStrategy:e?.dev?"fs":"rsc-module"}function At(e,t){if(!t)return e;let r=e.includes("?")?"&":"?";return`${e}${r}v=${encodeURIComponent(t)}`}function B(e,t){if(!t?.startsWith("on:"))return e;let r=e.indexOf("#"),n=r===-1?"":e.slice(r),o=r===-1?e:e.slice(0,r),s=o.indexOf("?"),a=s===-1?o:o.slice(0,s),u=new URLSearchParams(s===-1?"":o.slice(s+1));u.set("pins",t);let d=u.toString();return`${a}${d?`?${d}`:""}${n}`}function bt(e,t){return At(`${Te}${te(e)}.js`,t)}function Ot(e,t,r){let n=t?`&v=${encodeURIComponent(t)}`:"";return B(`${I}module?rel=${encodeURIComponent(e)}${n}`,r)}function D(e){let t=e?.dependencyPinningCacheKey;return t?.startsWith("on:")?{[k]:t}:{}}function It(e){return e.replace(/^\\/+_vf_modules\\//,"").replace(/^\\/+/,"").replace(/\\.js$/,"")}var Nt=/\\.(tsx|ts|jsx|mdx|js)$/;function Dt(e){let t=It(e),r=[e,t];return Nt.test(t)||r.push(`${t}.tsx`,`${t}.ts`,`${t}.jsx`,`${t}.mdx`,`${t}.js`),Array.from(new Set(r))}function wt(e,t){if(!e)return null;for(let r of Dt(t)){let n=e[r];if(n)return n}return null}function G(e){if(e.strategy==="fs"){let r=e.absPath??e.rel;return r?B(bt(r,e.version),e.dependencyPinningCacheKey):null}let t=wt(e.releaseAssetModules,e.rel);return t||Ot(e.rel,e.version,e.dependencyPinningCacheKey)}function j(e=document,t=O){let r=re(e);return{react:v("react",r)?"react":Re(t),reactDomClient:v("react-dom/client",r)?"react-dom/client":he(t)}}function Se(e=document){let t=re(e);return v("veryfront/router",t)?"veryfront/router":null}var Mt=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Lt(e,...t){let r=Object.create(null),n=e.charAt(0).toUpperCase()+e.slice(1);for(let o of t)for(let[s,a]of Object.entries(o)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${n} entry "${s}" must define a slug`);let u=a.slug;if(typeof u!="string")throw new Error(`${n} entry "${s}" must define a string slug`);if(u!==s)throw new Error(`${n} key "${s}" does not match entry slug "${u}"`);if(Object.hasOwn(r,s))throw new Error(`Duplicate ${e} slug "${s}"`);r[s]=a}return Object.freeze(r)}function Ce(...e){for(let t of e)for(let r of Object.values(t)){if(typeof r.slug!="string"||r.slug.length<3||r.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(r.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${r.slug}"`);if(typeof r.category!="string"||!Mt.has(r.category))throw new TypeError(`Registered error has unknown category "${r.category}"`);if(!Number.isInteger(r.status)||r.status<400||r.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${r.status}`);if(typeof r.title!="string"||r.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(r.suggestion!==void 0&&(typeof r.suggestion!="string"||r.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Lt("error registry",...e)}var z={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},Ai={debug:z.gray,info:z.green,warn:z.yellow,error:z.red};var y="[REDACTED]",E=Reflect.apply;var Ae=RegExp.prototype.exec,x=RegExp.prototype[Symbol.replace],Oi=String.prototype.charCodeAt,be=String.prototype.slice,Pt=String.prototype.toLowerCase,Ht=/[^a-z0-9]/g;function ie(e){let t=E(Pt,e,[]);return E(x,Ht,[t,""])}function Y(e,t,r){return r===void 0?E(be,e,[t]):E(be,e,[t,r])}var Ut=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],vt=512,kt=128,w=new Map;function Ne(e){let t=e.length<=kt;if(t){let o=w.get(e);if(o!==void 0)return o}let r=ie(e),n=Ut.some(o=>r.includes(o));if(t){if(w.size>=vt){let o=w.keys().next().value;o!==void 0&&w.delete(o)}w.set(e,n)}return n}var $t=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],Vt=new Set($t.map(ie)),Ft=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Bt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Gt=3;function jt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function zt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function De(e){return zt(e)||e==="_"||e==="$"}function Yt(e){if(!e)return!1;let t=e.charCodeAt(0);return De(e)||t>=48&&t<=57||e==="."||e==="-"}function we(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!De(e[r]))return!1;for(r++;Yt(e[r]);)r++;if(n){if(e[r]!==n)return!1;r++}for(;e[r]===" "||e[r]==="\t";)r++;return e[r]===":"||e[r]==="="}function Me(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||jt(e)}function Le(e,t){let r=t;for(;r=e.length||we(e,r)}function Kt(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(Oe(e,g))return{end:g,replacement:y};r=g,n=!1}let o=n&&(e[r]===\'"\'||e[r]==="\'"||e[r]==="`")?e[r]:"",s=!1,a=()=>o?`${o}${y}${s?o:""}`:y,u=[],d="",c=-1;for(let g=r;g0&&(f==="}"||f==="]")){if(u.at(-1)!==f)return{end:e.length,replacement:a()};if(u.pop(),g++,u.length===0&&Oe(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!Me(f)){g++;continue}let R=g;if(g=Le(e,g),g>=e.length||we(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function Ie(e,t,r,n){let o=0,s="";for(let a=E(Ae,t,[e]);a;a=E(Ae,t,[e])){let u=a[r];if(!Ne(u))continue;let d=t.lastIndex,c=n===void 0?void 0:a[n],g=d+y.length;if((c==="?"||c==="&"||c===";")&&e.startsWith(y,d)&&e[g]==="#")continue;let f=Kt(e,d);s+=Y(e,o,a.index),s+=a[0],s+=f.replacement,o=f.end,t.lastIndex=f.end}return o===0?e:s+Y(e,o)}function Wt(e,t,r){let n=r.search(/[ \\t]/);if(n<0)return!1;let o=`${t}:${Y(r,0,n)}`,s=e==="//"?`https://${o}`:`${e}${o}`;try{let a=new URL(s);return a.username.length===0&&a.password.length===0}catch{return!1}}function Xt(e){let t=e;for(let r=0;r{let s=o.indexOf(":");if(s===-1)return`${n}${y}@`;let a=Y(o,0,s);return`${n}${a}:${y}@`}]);return t=E(x,Bt,[t,(r,n,o,s)=>Wt(n,o,s)?r:`${n}${o}:${y}@`]),t=E(x,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,o,s)=>{let a=Xt(o);return Vt.has(ie(a))||Ne(a)?`${n}${o}=${y}`:r}]),t=E(x,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=E(x,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(x,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=Ie(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=Ie(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var qt=2048;var Mi=64*1024,Jt=256,Zt="https://veryfront.com/docs/errors/",Pe="...[truncated]",ae="unknown-error";function He(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Pe.length);return`${Qt(e,r)}${Pe}`}function Qt(e,t){let r=e.slice(0,t),n=r.charCodeAt(r.length-1);return n>=55296&&n<=56319&&(r=r.slice(0,-1)),r}function er(e){let t="";for(let r=0;r=55296&&n<=56319){let o=e.charCodeAt(r+1);o>=56320&&o<=57343?(t+=e.slice(r,r+2),r++):t+="\\uFFFD";continue}t+=n>=56320&&n<=57343?"\\uFFFD":e.charAt(r)}return t}function C(e){return typeof e!="string"?y:He(se(e),qt)}function tr(e){let t=typeof e=="string"?se(e):ae,r=He(t||ae,Jt),n=er(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(tr(e));return`${Zt}${t}`}var rr=Object.freeze,nr=Object.getOwnPropertyDescriptors,Ue=Number.isFinite,ke=new WeakSet,or=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function i(e){let t={...e},r={...t,create(n){let o=n?.message,s=n?.detail,a=n?.cause,u=n?.instance,d=n?.context,c=n?.status??t.status;return new ce(o||s||t.title,{slug:t.slug,category:t.category,status:c,title:t.title,suggestion:t.suggestion,exitCode:t.exitCode,detail:s,cause:a,instance:u,context:d})}};return rr(r)}var ce=class extends Error{constructor(r,n){super(r);m(this,"slug");m(this,"category");m(this,"status");m(this,"title");m(this,"suggestion");m(this,"exitCode");m(this,"detail");m(this,"cause");m(this,"instance");m(this,"context");ke.add(this),this.name="VeryfrontError",this.slug=n.slug,this.category=n.category,this.status=n.status,this.title=n.title,this.suggestion=n.suggestion,this.exitCode=n.exitCode,this.detail=n.detail,this.cause=n.cause,this.instance=n.instance,this.context=n.context}toRFC9457(){let r=ve(this);return r?{type:K(r.slug),title:C(r.title),status:r.status,detail:r.detail===void 0?void 0:C(r.detail),instance:r.instance===void 0?void 0:C(r.instance),category:r.category,suggestion:r.suggestion===void 0?void 0:C(r.suggestion),cause:typeof r.cause=="string"?C(r.cause):void 0}:{type:K("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let r=ve(this);return K(r?.slug??"unknown-error")}};function $e(e){return typeof e=="object"&&e!==null&&ke.has(e)}function ve(e){return $e(e)?ir(e):null}function ir(e){try{if(!$e(e))return null;let t=nr(e),r=J=>{let b=t[J];return b&&"value"in b?b.value:void 0},n=r("slug"),o=r("category"),s=r("status"),a=r("title"),u=r("message"),d=r("suggestion"),c=r("exitCode"),g=r("detail"),f=r("cause"),R=r("instance"),P=r("context"),h=r("stack");return typeof n!="string"||!or.has(o)||typeof s!="number"||!Ue(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!Ue(c))||g!==void 0&&typeof g!="string"||R!==void 0&&typeof R!="string"||h!==void 0&&typeof h!="string"?null:{slug:n,category:o,status:s,title:a,message:u,suggestion:d,exitCode:c,detail:g,cause:f,instance:R,context:P,stack:h}}catch{return null}}var sr=i({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),ar=i({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),cr=i({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ur=i({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),lr=i({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),dr=i({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),gr=i({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),fr=i({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),pr=i({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),yr=i({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),mr=i({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Ve={"config-not-found":sr,"config-invalid":ar,"config-parse-error":cr,"config-validation-error":ur,"config-type-error":lr,"import-map-invalid":dr,"cors-config-invalid":gr,"config-validation-failed":fr,"webhook-config-invalid":pr,"schedule-config-invalid":yr,"trigger-config-invalid":mr};var Er=i({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Rr=i({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),hr=i({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),_r=i({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),xr=i({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Tr=i({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Sr=i({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Cr=i({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Fe={"build-failed":Er,"bundle-error":Rr,"typescript-error":hr,"mdx-compile-error":_r,"asset-optimization-error":xr,"ssg-generation-error":Tr,"sourcemap-error":Sr,"compilation-error":Cr};var Ar=i({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),br=i({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Or=i({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Ir=i({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Nr=i({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),Dr=i({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),wr=i({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Mr=i({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),Lr=i({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),Pr=i({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),Be={"hydration-mismatch":Ar,"render-error":br,"component-error":Or,"layout-not-found":Ir,"page-not-found":Nr,"api-error":Dr,"middleware-error":wr,"trigger-target-not-found":Mr,"trigger-execution-failed":Lr,"trigger-not-supported":Pr};var Hr=i({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Ur=i({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),vr=i({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),kr=i({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),$r=i({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Vr=i({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),Ge={"route-conflict":Hr,"invalid-route-file":Ur,"route-handler-invalid":vr,"dynamic-route-error":kr,"route-params-error":$r,"api-route-error":Vr};var Fr=i({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Br=i({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Gr=i({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),jr=i({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),zr=i({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Yr=i({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),je={"module-not-found":Fr,"import-resolution-error":Br,"circular-dependency":Gr,"invalid-import":jr,"dependency-missing":zr,"version-mismatch":Yr};var Kr=i({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),Wr=i({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),Xr=i({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),qr=i({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Jr=i({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Zr=i({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Qr=i({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),en=i({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),tn=i({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),rn=i({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),nn=i({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),on=i({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),sn=i({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),an=i({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),cn=i({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),un=i({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ze={"port-in-use":Kr,"server-start-error":Wr,"cache-error":Xr,"file-watch-error":qr,"request-error":Jr,"service-overloaded":Zr,"project-execution-unavailable":Qr,"semaphore-timeout":en,"circuit-breaker-open":tn,"cache-path-mismatch":rn,"network-error":nn,"api-client-error":on,"token-storage-error":sn,"cache-invariant-violation":an,"release-not-found":cn,"fallback-exhausted":un};var ln=i({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),dn=i({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),gn=i({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),fn=i({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),pn=i({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),yn=i({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),mn=i({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),Ye={"client-boundary-violation":ln,"server-only-in-client":dn,"client-only-in-server":gn,"invalid-use-client":fn,"invalid-use-server":pn,"rsc-payload-error":yn,"ssr-output-limit-exceeded":mn};var En=i({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),Rn=i({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),hn=i({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),_n=i({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),xn=i({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),Ke={"hmr-error":En,"dev-server-error":Rn,"fast-refresh-error":hn,"error-overlay-error":_n,"source-map-error":xn};var Tn=i({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),Sn=i({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),Cn=i({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),An=i({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),bn=i({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),On=i({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),In=i({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Nn=i({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),Dn=i({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),wn=i({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Mn=i({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Ln=i({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),We={"deployment-error":Tn,"platform-error":Sn,"env-var-missing":Cn,"production-build-required":An,"environment-not-found":bn,"release-missing-version":On,"release-build-timeout":In,"deployment-verification-timeout":Nn,"push-receipt-missing":Dn,"source-digest-mismatch":wn,"preview-hostname-too-long":Mn,"branch-not-found":Ln};var Pn=i({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Hn=i({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Un=i({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),vn=i({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),kn=i({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),$n=i({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Vn=i({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),Xe={"agent-error":Pn,"agent-not-found":Hn,"agent-timeout":Un,"agent-intent-error":vn,"orchestration-error":kn,"cost-limit-exceeded":$n,"tool-id-conflict":Vn};var Fn=i({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Bn=i({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Gn=i({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),jn=i({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),zn=i({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Yn=i({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Kn=i({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),Wn=i({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Xn=i({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),ue=i({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),qn=i({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Jn=i({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),qe={"unknown-error":Fn,"authentication-required":Bn,"permission-denied":Gn,"file-not-found":jn,"resource-not-found":zn,"invalid-argument":Yn,"timeout-error":Kn,"initialization-error":Wn,"not-supported":Xn,"security-violation":ue,"input-validation-failed":qn,"project-source-empty":Jn};var _s=Ce(Ve,Fe,Be,Ge,je,ze,Ye,Ke,We,Xe,qe);var Zn=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Qn(){return Zn.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function eo(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function M(e,t={}){let{allowInlineScripts:r=!1,strict:n=!1,warn:o=!0}=t;for(let{pattern:s,name:a}of Qn())if(!(r&&a==="inline script")&&(s.lastIndex=0,!!s.test(e)&&(o&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),n||!eo())))throw ue.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}function L(e,t){let r=t==="root"?N:`rsc-slot-${t}`,n=e.getElementById(r);if(n)return n;let o=e.createElement("div");return o.id=r,e.body.appendChild(o),o}function to(e,t){if(t.type!=="slot")return;let r=L(e,t.id);r.innerHTML=M(String(t.html??""))}function Je(e,t){let r=t.split(`\n`),n=r.pop()??"";for(let o of r){let s=o.trim();if(!s)continue;let a;try{a=JSON.parse(s)}catch(d){l.debug("[client-dom] malformed NDJSON line",{line:s,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let u=a;if(u.type==="slot"){to(e,u);try{oo(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function ro(e){return new Promise((t,r)=>{let n=()=>r(new DOMException("aborted","AbortError"));if(e.aborted){n();return}e.addEventListener("abort",n,{once:!0})})}async function Ze(e,t=document,r){let n="body"in e?e:null,o=n?.body??e;if(!o)return;n&&V(t,n.headers.get(k));let s=o.getReader(),a=new TextDecoder,u="",d=!1;try{for(;;){if(r?.aborted)throw new DOMException("aborted","AbortError");let c=s.read(),{done:g,value:f}=r?await Promise.race([c,ro(r)]):await c;if(g){d=!0;break}u+=a.decode(f,{stream:!0}),u=Je(t,u)}u&&Je(t,`${u}\n`)}catch(c){throw c instanceof Error&&c.name==="AbortError"||l.debug("[client-dom] consumeNdjsonStream error",c),c}finally{try{await s.cancel()}catch(c){d||l.debug("[client-dom] reader.cancel failed",c)}try{s.releaseLock()}catch(c){l.debug("[client-dom] reader.releaseLock failed",c)}if(typeof o.cancel=="function")try{await o.cancel()}catch(c){l.debug("[client-dom] stream.cancel failed",c)}if(typeof n?.body?.cancel=="function")try{await n.body.cancel()}catch(c){l.debug("[client-dom] response.body.cancel failed",c)}}}function no(e,t){let r=L(e,t),n=[],o=s=>{let a=s;a.dataset?.clientRef&&n.push(a);for(let u of s.children)o(u)};return o(r),n}function oo(e,t){let r=no(e,t);for(let n of r){let o=n.dataset?.clientRef;o&&(n.dataset.hydrated="true",l.debug("[client-dom] marked for hydration",o))}}var io=new Set(["server","client","html","fragment"]);function Qe(e){if(!e)return[];try{let t=JSON.parse(e);return ao(t)?t.nodes:[]}catch{return[]}}async function de(e,t,r){return await Promise.all(e.map(n=>so(n,t,r)))}async function so(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await de(e.children??[],t,r);if(e.type==="fragment"||e.type==="server"&&!e.component)return t.createElement(t.Fragment,{},...n);if(e.type==="server")return t.createElement(e.component,e.props??{},...n);let o=await r(e.component);return o?t.createElement(o,e.props??{},...n):null}function ao(e){return!le(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>et(t,0))}function et(e,t){return t>100||!le(e)||!io.has(e.type)||e.type==="html"&&typeof e.html!="string"&&typeof e.text!="string"||e.type==="client"&&typeof e.component!="string"||e.type==="server"&&e.component!==void 0&&typeof e.component!="string"||e.props!==void 0&&!le(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>et(r,t+1))}function le(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function co(e){if(!e)return{};let t={};for(let[r,n]of Object.entries(e))t[r]=Array.isArray(n)?n.join("/"):n;return t}async function W(e,t,r=document){try{let n=Se(r);if(!n)return e;let s=(await import(n)).wrapForHydration;return typeof s!="function"?e:s(e,{params:co(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var uo="Unknown dependency snapshot",lo="export default null; // Unknown dependency snapshot",ge="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function go(){return globalThis}async function fo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===uo||t===lo}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await fo(e))return!1;let r=go();if(r[ge])return!0;r[ge]=!0;try{t()}catch{return delete r[ge],!1}return!0}async function X(e,t=globalThis.fetch,r=()=>globalThis.location.reload()){try{let n=new URL(e,"http://veryfront.local").searchParams.getAll("pins");if(n.length!==1||!n[0]?.startsWith("on:"))return!1;let o=await t(e,{cache:"no-store"});return await A(o,r)}catch{return!1}}var po=100;function yo(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=po){let r=globalThis.__VF_CLIENT_MOD_CACHE.keys().next().value;r&&globalThis.__VF_CLIENT_MOD_CACHE.delete(r)}globalThis.__VF_CLIENT_MOD_CACHE.set(e,t)}function tt(e){let t=e.match(/^\\/app\\/(.+)#([\\w$.-]+)$/);if(t)return{rel:`/${t[1]||""}`,exportName:t[2]||"default"};let r=e.match(/^(\\/_veryfront\\/[^#]+)#([\\w$.-]+)$/);return r?{moduleUrl:r[1],exportName:r[2]||"default"}:(l.debug("hydrate: unrecognised client ref format, skipping",{ref:e}),null)}function mo(e){let t=e.dataset?.rscProps;if(!t)return{};try{let r=JSON.parse(t);return r&&typeof r=="object"&&!Array.isArray(r)?r:{}}catch(r){return l.debug("hydrate: invalid client boundary props, using empty props",r),{}}}function Eo(e){return Qe(e.dataset?.rscChildren)}function Ro(e){return"/_veryfront/rsc/manifest"}function ho(e){return D(e)}async function _o(e=document){try{let t=S(e),r=await fetch(Ro(t),{headers:ho(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function rt(e,t,r,n={}){let o=xo(e,t,r,n.releaseAssetModules),s=t.moduleUrl??t.rel;if(!s)return null;let a=`${s}#${e.hash??""}`;try{let u=globalThis.__VF_CLIENT_MOD_CACHE?.get(a);if(u)return u}catch(u){l.debug("hydrate: cache get failed",u)}if(!o)return null;try{let u=await(n.importModule??(d=>import(d)))(o);try{yo(a,u)}catch(d){l.debug("hydrate: cache set failed",d)}return u}catch(u){return l.debug("hydrate: failed to import module",{moduleUrl:o,error:u}),await(n.recoverSnapshotFailure??X)(o),null}}function xo(e,t,r,n){if(t.moduleUrl)return B(t.moduleUrl,e.dependencyPinningCacheKey);if(!t.rel)return null;let o=e.graphIds?.client.find(s=>s.rel===t.rel)?.path;return G({strategy:r,rel:t.rel,absPath:o,version:e.hash,dependencyPinningCacheKey:e.dependencyPinningCacheKey,releaseAssetModules:n})}function To(e){let t=Array.from(e.querySelectorAll("[data-client-ref]")),r=new Set(t);return t.filter(n=>{let o=n.parentElement;for(;o;){if(r.has(o))return!1;o=o.parentElement}return!0})}async function nt(e=document){let t=null;try{t=await _o(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=To(e);try{let c=globalThis.__VF_MANIFEST_HASH;if(!r.some(f=>f.dataset?.hydrated!=="true")&&c&&t.hash&&c===t.hash)return}catch(c){l.debug("hydrate: hmr hash read failed",c)}if(r.length===0){try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed",c)}return}let n=S(e),o=F(n),s=n?.releaseAssetModules;try{if(globalThis.__VF_TEST_MODE__){globalThis.__VF_HYDRATE_CALLED=!0,globalThis.__VF_MANIFEST_HASH=t.hash??"";return}}catch(c){l.debug("hydrate: test mode flags failed",c)}let a=j(e,n?.reactVersion),[{default:u},{createRoot:d}]=await Promise.all([import(a.react),import(a.reactDomClient)]);for(let c of r){let g=c.dataset?.clientRef??"";if(!g||c.dataset?.hydrated==="true")continue;let f=tt(g);if(!f)continue;let R=await rt(t,f,o,{releaseAssetModules:s});if(!R)continue;let P=R[f.exportName]??R.default;if(typeof P=="function")try{let h=d(c),J=mo(c),b=Eo(c),ot=await de(b,{Fragment:u.Fragment,createElement(H,Z,...U){return u.createElement(H,Z,...U)}},async H=>{let Z=t.modules.find(st=>st.id===H),U=t.components?.[H],ye=Z?.clientRef??(U?`${U}#default`:void 0);if(!ye)return null;let Q=tt(ye);if(!Q)return null;let ee=await rt(t,Q,o,{releaseAssetModules:s});if(!ee)return null;let me=ee[Q.exportName]??ee.default;return typeof me=="function"?me:null}),it=await W(u.createElement(P,J,...ot),n,e);h.render(it),c.dataset.hydrated="true"}catch(h){l.warn("hydrate: render failed",h)}}try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed (post)",c)}}var fe="data-vf-react-head-owner";var So=2*1024*1024,Zs=So*2;var Qs=64*1024,ea=1024*1024,ta=1024*1024;var ra=new TextEncoder;async function Co(){let e=S(document),t=j(document,e?.reactVersion),[r,n]=await Promise.all([import(t.react),import(t.reactDomClient)]);return{React:r,ReactDOM:n}}var Ao=new Set(["SCRIPT","STYLE","NOSCRIPT","TEMPLATE"]);function pe(e){let t=e.getAttribute("style")??"";return e.hasAttribute("data-veryfront-head")||e.hasAttribute("hidden")||/(?:^|;)\\s*display\\s*:\\s*none(?:\\s*;|$)/i.test(t)||Ao.has(e.tagName.toUpperCase())}function bo(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function Oo(e,t){return e===t}function Io(e,t){let r=document.createElement("div");r.setAttribute("data-veryfront-hydration-root","page");let n=e.find(o=>!pe(o));n?.parentNode===t?t.insertBefore(r,n):t.appendChild(r);for(let o of e)!pe(o)&&o.parentNode===t&&r.appendChild(o);return r}function No(e,t){for(let r of e){let n=[...r.hasAttribute(fe)?[r]:[],...r.querySelectorAll(`[${fe}]`)];for(let o of n)t.contains(o)||o.remove()}}function Do(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function wo(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function Mo(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function Lo(e){return e==="rsc-module"}function Po(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function Ho(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function Uo(e,t){try{let r=await fetch(I+"stream"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";if(!r.body)return"failure";let n=new AbortController;return addEventListener("pagehide",()=>n.abort(),{once:!0}),await Ze(r,document,n.signal),"success"}catch(r){return l.debug("tryStream failed",r),"failure"}}async function q(){try{await nt(document)}catch(e){l.debug("hydration failed",e)}}async function vo(e,t,r){try{let{React:n,ReactDOM:o}=await Co(),s=Ho(e,t,r);if(!s)return!1;l.debug("Loading component from:",s);let a;try{a=await import(s)}catch(R){throw await X(s),R}let u=a.default;if(typeof u!="function")return l.debug("Page component is not a function"),!1;let d=Array.from(document.body.children),c=bo(d,document.body),g=Oo(c,document.body)?Io(d,document.body):c;No(d,g);let f=await W(n.createElement(u,{}),r);return Lo(t)?o.createRoot(g).render(f):o.hydrateRoot(g,f,{identifierPrefix:"vf",onRecoverableError:()=>{}}),l.debug("Page component hydrated successfully"),!0}catch(n){return l.error("Page hydration failed",n),!1}}async function ko(e,t){try{let r=await fetch(I+"payload"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";let n=await r.json();if(V(document,n?.dependencyPinningCacheKey),n?.slots){for(let[o,s]of Object.entries(n.slots))L(document,o).innerHTML=M(String(s||""));return"success"}return L(document,N).innerHTML=M(String(n?.html||"")),"success"}catch(r){return l.debug("payload fetch failed",r),"failure"}}async function $o(){try{let e=S(document),t=Po(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(Mo()){await q();return}let r=e?.pagePath,n=F(e);if(r){if(Do(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await vo(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!wo(document,e))return;let o=await Uo(t,e);if(o==="snapshot-conflict")return;if(o==="success"){await q();return}let s=await ko(t,e);if(s==="snapshot-conflict")return;if(s==="success"){await q();return}await q()}catch(e){l.error("boot failed",e)}}if(typeof document<"u"){let e=()=>{$o()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{$o as boot,Ho as buildPageHydrationModuleUrl,Po as buildRSCTransportQuery,No as retireAbandonedHeadOwnerMarkers,bo as selectHydrationRoot,wo as shouldAttemptRSCTransport,Mo as shouldHydrateOnly,Lo as shouldRenderPageComponent,Do as shouldUsePageRendererHydration,Oo as shouldWrapPageHydrationRoot};\n'; + 'var at=Object.defineProperty;var ct=(e,t,r)=>t in e?at(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var m=(e,t,r)=>ct(e,typeof t!="symbol"?t+"":t,r);var ut="3.2.3";function lt(e,t,r,n){let i=[];if(n?.external?.length&&i.push(`external=${n.external.join(",")}`),i.push(`target=${n?.target??"es2022"}`),n?.deps){let d=Object.entries(n.deps).map(([c,g])=>`${c}@${g}`).join(",");i.push(`deps=${d}`)}let s=t?`@${t}`:"",a=r??"",u=i.length?`?${i.join("&")}`:"";return`https://esm.sh/${e}${s}${a}${u}`}function _(e,t,r,n=!1){return lt(e,t,r,{external:n?["react"]:void 0,deps:{csstype:ut}})}var gt="19.2.4",O=gt;function Ee(e=O){return{react:_("react",e),"react-dom":_("react-dom",e,void 0,!0),"react-dom/client":_("react-dom",e,"/client",!0),"react-dom/server":_("react-dom",e,"/server",!0),"react/jsx-runtime":_("react",e,"/jsx-runtime",!0),"react/jsx-dev-runtime":_("react",e,"/jsx-dev-runtime",!0)}}function Re(e=O){return Ee(e).react}function he(e=O){return Ee(e)["react-dom/client"]}function ft(e){return e.replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}function pt(e){if(typeof globalThis.btoa=="function")try{return globalThis.btoa(e)}catch{return yt(new TextEncoder().encode(e))}let t=globalThis.Buffer;if(t)return t.from(e,"utf8").toString("base64");throw new Error("Base64 encoding is not supported in this runtime")}function yt(e){let t=globalThis.Buffer;if(t)return t.from(e).toString("base64");if(typeof globalThis.btoa=="function"){let r="";for(let n of e)r+=String.fromCharCode(n);return globalThis.btoa(r)}throw new Error("Base64 encoding is not supported in this runtime")}function te(e){return ft(pt(e))}var Zo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function v(e,t){if(!t)return!1;if(Object.prototype.hasOwnProperty.call(t,e))return!0;for(let r of Object.keys(t))if(r.endsWith("/")&&e.startsWith(r))return!0;return!1}function mt(e){try{return JSON.parse(e)?.imports??{}}catch(t){return console.warn("Failed to parse import map JSON; treating as empty",{errorName:t instanceof Error?t.name:typeof t,inputLength:e.length}),{}}}function re(e=document){let t=e.querySelector(\'script[type="importmap"]\');return t?.textContent?mt(t.textContent):{}}var ei=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Et=5e3,Rt=1e4,ni=16*1024*1024,ht=5e3;var _t=100;var xt=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),oi=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),ii=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Et,api:3e4,ssr:Rt,hmr:3e4,sandbox:ht}),cache:Object.freeze({jit:Object.freeze({maxSize:_t,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:xt})});var p="/_veryfront",ne={RSC:`${p}/rsc/`,FS:`${p}/fs/`,MODULES:`${p}/modules/`,PAGES:`${p}/pages/`,DATA:`${p}/data/`,LIB:`${p}/lib/`,CHUNKS:`${p}/chunks/`,CLIENT:`${p}/client/`},xe={HMR_RUNTIME:`${p}/hmr-runtime.js`,HMR:`${p}/hmr.js`,ERROR_OVERLAY:`${p}/error-overlay.js`,DEV_LOADER:`${p}/dev-loader.js`,CLIENT_LOG:`${p}/log`,CLIENT_JS:`${p}/client.js`,ROUTER_JS:`${p}/router.js`,PREFETCH_JS:`${p}/prefetch.js`,MANIFEST_JSON:`${p}/manifest.json`,APP_JS:`${p}/app.js`,RSC_CLIENT:`${p}/rsc/client.js`,RSC_MANIFEST:`${p}/rsc/manifest`,RSC_STREAM:`${p}/rsc/stream`,RSC_PAYLOAD:`${p}/rsc/payload`,RSC_RENDER:`${p}/rsc/render`,RSC_PAGE:`${p}/rsc/page`,RSC_MODULE:`${p}/rsc/module`,RSC_DOM:`${p}/rsc/dom.js`,LIB_CHAT_REACT:`${p}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${p}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${p}/lib/chat/primitives.js`};var Tt={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},ai=Tt.CACHE;var ci={HMR_RUNTIME:xe.HMR_RUNTIME,ERROR_OVERLAY:xe.ERROR_OVERLAY};var I=ne.RSC,Te=ne.FS;var N="rsc-root",k="x-veryfront-dependency-pins";var T=class{constructor(t,r){m(this,"prefix",t);m(this,"level",r)}log(t,r,n,...i){this.level>t||r?.(n,...i)}debug(t,...r){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${t}`,...r)}info(t,...r){this.log(1,console.log,`[${this.prefix}] ${t}`,...r)}warn(t,...r){this.log(2,console.warn,`[${this.prefix}] WARN: ${t}`,...r)}error(t,...r){this.log(3,console.error,`[${this.prefix}] ERROR: ${t}`,...r)}};function St(){if(typeof window>"u")return 2;let e=globalThis;return e.__VERYFRONT_DEV__||e.__RSC_DEV__?e.__VERYFRONT_DEBUG__||e.__RSC_DEBUG__?0:1:2}var $=St(),l=new T("RSC",$),fi=new T("PREFETCH",$),pi=new T("HYDRATE",$),yi=new T("VERYFRONT",$);var Ct="veryfront-hydration-data";function oe(e){try{let t=[...e.querySelectorAll(`[id="${Ct}"]`)];if(t.length!==1)return null;let r=e.body;if(!r)return null;let n=t[0];return r.firstElementChild!==n&&n.parentElement!==r||n.tagName?.toLowerCase()!=="script"||n.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:n}catch{return null}}function S(e=document){try{let t=oe(e);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function V(e,t){if(!t?.startsWith("on:"))return!1;try{let r=oe(e);if(!r)return!1;let n=JSON.parse(r.textContent||"{}");return n.dependencyPinningCacheKey=t,r.textContent=JSON.stringify(n),!0}catch(r){return l.debug("hydration dependency snapshot seed failed",r),!1}}function F(e){return e?.clientModuleStrategy?e.clientModuleStrategy:e?.dev?"fs":"rsc-module"}function At(e,t){if(!t)return e;let r=e.includes("?")?"&":"?";return`${e}${r}v=${encodeURIComponent(t)}`}function B(e,t){if(!t?.startsWith("on:"))return e;let r=e.indexOf("#"),n=r===-1?"":e.slice(r),i=r===-1?e:e.slice(0,r),s=i.indexOf("?"),a=s===-1?i:i.slice(0,s),u=new URLSearchParams(s===-1?"":i.slice(s+1));u.set("pins",t);let d=u.toString();return`${a}${d?`?${d}`:""}${n}`}function bt(e,t){return At(`${Te}${te(e)}.js`,t)}function Ot(e,t,r){let n=t?`&v=${encodeURIComponent(t)}`:"";return B(`${I}module?rel=${encodeURIComponent(e)}${n}`,r)}function D(e){let t=e?.dependencyPinningCacheKey;return t?.startsWith("on:")?{[k]:t}:{}}function It(e){return e.replace(/^\\/+_vf_modules\\//,"").replace(/^\\/+/,"").replace(/\\.js$/,"")}var Nt=/\\.(tsx|ts|jsx|mdx|js)$/;function Dt(e){let t=It(e),r=[e,t];return Nt.test(t)||r.push(`${t}.tsx`,`${t}.ts`,`${t}.jsx`,`${t}.mdx`,`${t}.js`),Array.from(new Set(r))}function wt(e,t){if(!e)return null;for(let r of Dt(t)){let n=e[r];if(n)return n}return null}function G(e){if(e.strategy==="fs"){let r=e.absPath??e.rel;return r?B(bt(r,e.version),e.dependencyPinningCacheKey):null}let t=wt(e.releaseAssetModules,e.rel);return t||Ot(e.rel,e.version,e.dependencyPinningCacheKey)}function j(e=document,t=O){let r=re(e);return{react:v("react",r)?"react":Re(t),reactDomClient:v("react-dom/client",r)?"react-dom/client":he(t)}}function Se(e=document){let t=re(e);return v("veryfront/router",t)?"veryfront/router":null}var Mt=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Lt(e,...t){let r=Object.create(null),n=e.charAt(0).toUpperCase()+e.slice(1);for(let i of t)for(let[s,a]of Object.entries(i)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${n} entry "${s}" must define a slug`);let u=a.slug;if(typeof u!="string")throw new Error(`${n} entry "${s}" must define a string slug`);if(u!==s)throw new Error(`${n} key "${s}" does not match entry slug "${u}"`);if(Object.hasOwn(r,s))throw new Error(`Duplicate ${e} slug "${s}"`);r[s]=a}return Object.freeze(r)}function Ce(...e){for(let t of e)for(let r of Object.values(t)){if(typeof r.slug!="string"||r.slug.length<3||r.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(r.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${r.slug}"`);if(typeof r.category!="string"||!Mt.has(r.category))throw new TypeError(`Registered error has unknown category "${r.category}"`);if(!Number.isInteger(r.status)||r.status<400||r.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${r.status}`);if(typeof r.title!="string"||r.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(r.suggestion!==void 0&&(typeof r.suggestion!="string"||r.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Lt("error registry",...e)}var z={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},Oi={debug:z.gray,info:z.green,warn:z.yellow,error:z.red};var y="[REDACTED]",E=Reflect.apply;var Ae=RegExp.prototype.exec,x=RegExp.prototype[Symbol.replace],Ni=String.prototype.charCodeAt,be=String.prototype.slice,Pt=String.prototype.toLowerCase,Ht=/[^a-z0-9]/g;function ie(e){let t=E(Pt,e,[]);return E(x,Ht,[t,""])}function Y(e,t,r){return r===void 0?E(be,e,[t]):E(be,e,[t,r])}var Ut=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],vt=512,kt=128,w=new Map;function Ne(e){let t=e.length<=kt;if(t){let i=w.get(e);if(i!==void 0)return i}let r=ie(e),n=Ut.some(i=>r.includes(i));if(t){if(w.size>=vt){let i=w.keys().next().value;i!==void 0&&w.delete(i)}w.set(e,n)}return n}var $t=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],Vt=new Set($t.map(ie)),Ft=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Bt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Gt=3;function jt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function zt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function De(e){return zt(e)||e==="_"||e==="$"}function Yt(e){if(!e)return!1;let t=e.charCodeAt(0);return De(e)||t>=48&&t<=57||e==="."||e==="-"}function we(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!De(e[r]))return!1;for(r++;Yt(e[r]);)r++;if(n){if(e[r]!==n)return!1;r++}for(;e[r]===" "||e[r]==="\t";)r++;return e[r]===":"||e[r]==="="}function Me(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||jt(e)}function Le(e,t){let r=t;for(;r=e.length||we(e,r)}function Kt(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(Oe(e,g))return{end:g,replacement:y};r=g,n=!1}let i=n&&(e[r]===\'"\'||e[r]==="\'"||e[r]==="`")?e[r]:"",s=!1,a=()=>i?`${i}${y}${s?i:""}`:y,u=[],d="",c=-1;for(let g=r;g0&&(f==="}"||f==="]")){if(u.at(-1)!==f)return{end:e.length,replacement:a()};if(u.pop(),g++,u.length===0&&Oe(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!Me(f)){g++;continue}let R=g;if(g=Le(e,g),g>=e.length||we(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function Ie(e,t,r,n){let i=0,s="";for(let a=E(Ae,t,[e]);a;a=E(Ae,t,[e])){let u=a[r];if(!Ne(u))continue;let d=t.lastIndex,c=n===void 0?void 0:a[n],g=d+y.length;if((c==="?"||c==="&"||c===";")&&e.startsWith(y,d)&&e[g]==="#")continue;let f=Kt(e,d);s+=Y(e,i,a.index),s+=a[0],s+=f.replacement,i=f.end,t.lastIndex=f.end}return i===0?e:s+Y(e,i)}function Wt(e,t,r){let n=r.search(/[ \\t]/);if(n<0)return!1;let i=`${t}:${Y(r,0,n)}`,s=e==="//"?`https://${i}`:`${e}${i}`;try{let a=new URL(s);return a.username.length===0&&a.password.length===0}catch{return!1}}function Xt(e){let t=e;for(let r=0;r{let s=i.indexOf(":");if(s===-1)return`${n}${y}@`;let a=Y(i,0,s);return`${n}${a}:${y}@`}]);return t=E(x,Bt,[t,(r,n,i,s)=>Wt(n,i,s)?r:`${n}${i}:${y}@`]),t=E(x,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,i,s)=>{let a=Xt(i);return Vt.has(ie(a))||Ne(a)?`${n}${i}=${y}`:r}]),t=E(x,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,i)=>`${n}${i}${y}`]),t=E(x,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(x,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,i)=>`${n}${i}${y}`]),t=Ie(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=Ie(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var qt=2048;var Pi=64*1024,Jt=256,Zt="https://veryfront.com/docs/errors/",Pe="...[truncated]",ae="unknown-error";function He(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Pe.length);return`${Qt(e,r)}${Pe}`}function Qt(e,t){let r=e.slice(0,t),n=r.charCodeAt(r.length-1);return n>=55296&&n<=56319&&(r=r.slice(0,-1)),r}function er(e){let t="";for(let r=0;r=55296&&n<=56319){let i=e.charCodeAt(r+1);i>=56320&&i<=57343?(t+=e.slice(r,r+2),r++):t+="\\uFFFD";continue}t+=n>=56320&&n<=57343?"\\uFFFD":e.charAt(r)}return t}function C(e){return typeof e!="string"?y:He(se(e),qt)}function tr(e){let t=typeof e=="string"?se(e):ae,r=He(t||ae,Jt),n=er(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(tr(e));return`${Zt}${t}`}var rr=Object.freeze,nr=Object.getOwnPropertyDescriptors,Ue=Number.isFinite,ke=new WeakSet,or=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function o(e){let t={...e},r={...t,create(n){let i=n?.message,s=n?.detail,a=n?.cause,u=n?.instance,d=n?.context,c=n?.status??t.status;return new ce(i||s||t.title,{slug:t.slug,category:t.category,status:c,title:t.title,suggestion:t.suggestion,exitCode:t.exitCode,detail:s,cause:a,instance:u,context:d})}};return rr(r)}var ce=class extends Error{constructor(r,n){super(r);m(this,"slug");m(this,"category");m(this,"status");m(this,"title");m(this,"suggestion");m(this,"exitCode");m(this,"detail");m(this,"cause");m(this,"instance");m(this,"context");ke.add(this),this.name="VeryfrontError",this.slug=n.slug,this.category=n.category,this.status=n.status,this.title=n.title,this.suggestion=n.suggestion,this.exitCode=n.exitCode,this.detail=n.detail,this.cause=n.cause,this.instance=n.instance,this.context=n.context}toRFC9457(){let r=ve(this);return r?{type:K(r.slug),title:C(r.title),status:r.status,detail:r.detail===void 0?void 0:C(r.detail),instance:r.instance===void 0?void 0:C(r.instance),category:r.category,suggestion:r.suggestion===void 0?void 0:C(r.suggestion),cause:typeof r.cause=="string"?C(r.cause):void 0}:{type:K("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let r=ve(this);return K(r?.slug??"unknown-error")}};function $e(e){return typeof e=="object"&&e!==null&&ke.has(e)}function ve(e){return $e(e)?ir(e):null}function ir(e){try{if(!$e(e))return null;let t=nr(e),r=J=>{let b=t[J];return b&&"value"in b?b.value:void 0},n=r("slug"),i=r("category"),s=r("status"),a=r("title"),u=r("message"),d=r("suggestion"),c=r("exitCode"),g=r("detail"),f=r("cause"),R=r("instance"),P=r("context"),h=r("stack");return typeof n!="string"||!or.has(i)||typeof s!="number"||!Ue(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!Ue(c))||g!==void 0&&typeof g!="string"||R!==void 0&&typeof R!="string"||h!==void 0&&typeof h!="string"?null:{slug:n,category:i,status:s,title:a,message:u,suggestion:d,exitCode:c,detail:g,cause:f,instance:R,context:P,stack:h}}catch{return null}}var sr=o({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),ar=o({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),cr=o({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ur=o({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),lr=o({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),dr=o({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),gr=o({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),fr=o({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),pr=o({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),yr=o({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),mr=o({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Ve={"config-not-found":sr,"config-invalid":ar,"config-parse-error":cr,"config-validation-error":ur,"config-type-error":lr,"import-map-invalid":dr,"cors-config-invalid":gr,"config-validation-failed":fr,"webhook-config-invalid":pr,"schedule-config-invalid":yr,"trigger-config-invalid":mr};var Er=o({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Rr=o({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),hr=o({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),_r=o({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),xr=o({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Tr=o({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Sr=o({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Cr=o({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Fe={"build-failed":Er,"bundle-error":Rr,"typescript-error":hr,"mdx-compile-error":_r,"asset-optimization-error":xr,"ssg-generation-error":Tr,"sourcemap-error":Sr,"compilation-error":Cr};var Ar=o({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),br=o({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Or=o({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Ir=o({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Nr=o({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),Dr=o({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),wr=o({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Mr=o({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),Lr=o({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),Pr=o({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),Be={"hydration-mismatch":Ar,"render-error":br,"component-error":Or,"layout-not-found":Ir,"page-not-found":Nr,"api-error":Dr,"middleware-error":wr,"trigger-target-not-found":Mr,"trigger-execution-failed":Lr,"trigger-not-supported":Pr};var Hr=o({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Ur=o({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),vr=o({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),kr=o({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),$r=o({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Vr=o({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),Ge={"route-conflict":Hr,"invalid-route-file":Ur,"route-handler-invalid":vr,"dynamic-route-error":kr,"route-params-error":$r,"api-route-error":Vr};var Fr=o({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Br=o({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Gr=o({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),jr=o({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),zr=o({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Yr=o({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),je={"module-not-found":Fr,"import-resolution-error":Br,"circular-dependency":Gr,"invalid-import":jr,"dependency-missing":zr,"version-mismatch":Yr};var Kr=o({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),Wr=o({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),Xr=o({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),qr=o({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Jr=o({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Zr=o({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Qr=o({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),en=o({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),tn=o({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),rn=o({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),nn=o({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),on=o({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),sn=o({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),an=o({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),cn=o({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),un=o({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ln=o({slug:"rag-store-corrupt",category:"SERVER",status:500,title:"RAG store file is corrupt",suggestion:"Repair or move the store file aside, then retry; it was not overwritten"}),dn=o({slug:"rag-store-unavailable",category:"SERVER",status:500,title:"RAG store file is unavailable",suggestion:"Check storage availability, permissions, and concurrent operations, then retry"}),ze={"port-in-use":Kr,"server-start-error":Wr,"cache-error":Xr,"file-watch-error":qr,"request-error":Jr,"service-overloaded":Zr,"project-execution-unavailable":Qr,"semaphore-timeout":en,"circuit-breaker-open":tn,"cache-path-mismatch":rn,"network-error":nn,"api-client-error":on,"token-storage-error":sn,"cache-invariant-violation":an,"release-not-found":cn,"fallback-exhausted":un,"rag-store-corrupt":ln,"rag-store-unavailable":dn};var gn=o({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),fn=o({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),pn=o({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),yn=o({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),mn=o({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),En=o({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),Rn=o({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),Ye={"client-boundary-violation":gn,"server-only-in-client":fn,"client-only-in-server":pn,"invalid-use-client":yn,"invalid-use-server":mn,"rsc-payload-error":En,"ssr-output-limit-exceeded":Rn};var hn=o({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),_n=o({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),xn=o({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),Tn=o({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),Sn=o({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),Ke={"hmr-error":hn,"dev-server-error":_n,"fast-refresh-error":xn,"error-overlay-error":Tn,"source-map-error":Sn};var Cn=o({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),An=o({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),bn=o({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),On=o({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),In=o({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Nn=o({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),Dn=o({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),wn=o({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),Mn=o({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),Ln=o({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Pn=o({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Hn=o({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),We={"deployment-error":Cn,"platform-error":An,"env-var-missing":bn,"production-build-required":On,"environment-not-found":In,"release-missing-version":Nn,"release-build-timeout":Dn,"deployment-verification-timeout":wn,"push-receipt-missing":Mn,"source-digest-mismatch":Ln,"preview-hostname-too-long":Pn,"branch-not-found":Hn};var Un=o({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),vn=o({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),kn=o({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),$n=o({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Vn=o({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Fn=o({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Bn=o({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),Xe={"agent-error":Un,"agent-not-found":vn,"agent-timeout":kn,"agent-intent-error":$n,"orchestration-error":Vn,"cost-limit-exceeded":Fn,"tool-id-conflict":Bn};var Gn=o({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),jn=o({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),zn=o({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Yn=o({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Kn=o({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Wn=o({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Xn=o({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),qn=o({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Jn=o({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),ue=o({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),Zn=o({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Qn=o({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),qe={"unknown-error":Gn,"authentication-required":jn,"permission-denied":zn,"file-not-found":Yn,"resource-not-found":Kn,"invalid-argument":Wn,"timeout-error":Xn,"initialization-error":qn,"not-supported":Jn,"security-violation":ue,"input-validation-failed":Zn,"project-source-empty":Qn};var Ts=Ce(Ve,Fe,Be,Ge,je,ze,Ye,Ke,We,Xe,qe);var eo=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function to(){return eo.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function ro(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function M(e,t={}){let{allowInlineScripts:r=!1,strict:n=!1,warn:i=!0}=t;for(let{pattern:s,name:a}of to())if(!(r&&a==="inline script")&&(s.lastIndex=0,!!s.test(e)&&(i&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),n||!ro())))throw ue.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}function L(e,t){let r=t==="root"?N:`rsc-slot-${t}`,n=e.getElementById(r);if(n)return n;let i=e.createElement("div");return i.id=r,e.body.appendChild(i),i}function no(e,t){if(t.type!=="slot")return;let r=L(e,t.id);r.innerHTML=M(String(t.html??""))}function Je(e,t){let r=t.split(`\n`),n=r.pop()??"";for(let i of r){let s=i.trim();if(!s)continue;let a;try{a=JSON.parse(s)}catch(d){l.debug("[client-dom] malformed NDJSON line",{line:s,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let u=a;if(u.type==="slot"){no(e,u);try{so(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function oo(e){return new Promise((t,r)=>{let n=()=>r(new DOMException("aborted","AbortError"));if(e.aborted){n();return}e.addEventListener("abort",n,{once:!0})})}async function Ze(e,t=document,r){let n="body"in e?e:null,i=n?.body??e;if(!i)return;n&&V(t,n.headers.get(k));let s=i.getReader(),a=new TextDecoder,u="",d=!1;try{for(;;){if(r?.aborted)throw new DOMException("aborted","AbortError");let c=s.read(),{done:g,value:f}=r?await Promise.race([c,oo(r)]):await c;if(g){d=!0;break}u+=a.decode(f,{stream:!0}),u=Je(t,u)}u&&Je(t,`${u}\n`)}catch(c){throw c instanceof Error&&c.name==="AbortError"||l.debug("[client-dom] consumeNdjsonStream error",c),c}finally{try{await s.cancel()}catch(c){d||l.debug("[client-dom] reader.cancel failed",c)}try{s.releaseLock()}catch(c){l.debug("[client-dom] reader.releaseLock failed",c)}if(typeof i.cancel=="function")try{await i.cancel()}catch(c){l.debug("[client-dom] stream.cancel failed",c)}if(typeof n?.body?.cancel=="function")try{await n.body.cancel()}catch(c){l.debug("[client-dom] response.body.cancel failed",c)}}}function io(e,t){let r=L(e,t),n=[],i=s=>{let a=s;a.dataset?.clientRef&&n.push(a);for(let u of s.children)i(u)};return i(r),n}function so(e,t){let r=io(e,t);for(let n of r){let i=n.dataset?.clientRef;i&&(n.dataset.hydrated="true",l.debug("[client-dom] marked for hydration",i))}}var ao=new Set(["server","client","html","fragment"]);function Qe(e){if(!e)return[];try{let t=JSON.parse(e);return uo(t)?t.nodes:[]}catch{return[]}}async function de(e,t,r){return await Promise.all(e.map(n=>co(n,t,r)))}async function co(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await de(e.children??[],t,r);if(e.type==="fragment"||e.type==="server"&&!e.component)return t.createElement(t.Fragment,{},...n);if(e.type==="server")return t.createElement(e.component,e.props??{},...n);let i=await r(e.component);return i?t.createElement(i,e.props??{},...n):null}function uo(e){return!le(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>et(t,0))}function et(e,t){return t>100||!le(e)||!ao.has(e.type)||e.type==="html"&&typeof e.html!="string"&&typeof e.text!="string"||e.type==="client"&&typeof e.component!="string"||e.type==="server"&&e.component!==void 0&&typeof e.component!="string"||e.props!==void 0&&!le(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>et(r,t+1))}function le(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function lo(e){if(!e)return{};let t={};for(let[r,n]of Object.entries(e))t[r]=Array.isArray(n)?n.join("/"):n;return t}async function W(e,t,r=document){try{let n=Se(r);if(!n)return e;let s=(await import(n)).wrapForHydration;return typeof s!="function"?e:s(e,{params:lo(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var go="Unknown dependency snapshot",fo="export default null; // Unknown dependency snapshot",ge="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function po(){return globalThis}async function yo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===go||t===fo}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await yo(e))return!1;let r=po();if(r[ge])return!0;r[ge]=!0;try{t()}catch{return delete r[ge],!1}return!0}async function X(e,t=globalThis.fetch,r=()=>globalThis.location.reload()){try{let n=new URL(e,"http://veryfront.local").searchParams.getAll("pins");if(n.length!==1||!n[0]?.startsWith("on:"))return!1;let i=await t(e,{cache:"no-store"});return await A(i,r)}catch{return!1}}var mo=100;function Eo(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=mo){let r=globalThis.__VF_CLIENT_MOD_CACHE.keys().next().value;r&&globalThis.__VF_CLIENT_MOD_CACHE.delete(r)}globalThis.__VF_CLIENT_MOD_CACHE.set(e,t)}function tt(e){let t=e.match(/^\\/app\\/(.+)#([\\w$.-]+)$/);if(t)return{rel:`/${t[1]||""}`,exportName:t[2]||"default"};let r=e.match(/^(\\/_veryfront\\/[^#]+)#([\\w$.-]+)$/);return r?{moduleUrl:r[1],exportName:r[2]||"default"}:(l.debug("hydrate: unrecognised client ref format, skipping",{ref:e}),null)}function Ro(e){let t=e.dataset?.rscProps;if(!t)return{};try{let r=JSON.parse(t);return r&&typeof r=="object"&&!Array.isArray(r)?r:{}}catch(r){return l.debug("hydrate: invalid client boundary props, using empty props",r),{}}}function ho(e){return Qe(e.dataset?.rscChildren)}function _o(e){return"/_veryfront/rsc/manifest"}function xo(e){return D(e)}async function To(e=document){try{let t=S(e),r=await fetch(_o(t),{headers:xo(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function rt(e,t,r,n={}){let i=So(e,t,r,n.releaseAssetModules),s=t.moduleUrl??t.rel;if(!s)return null;let a=`${s}#${e.hash??""}`;try{let u=globalThis.__VF_CLIENT_MOD_CACHE?.get(a);if(u)return u}catch(u){l.debug("hydrate: cache get failed",u)}if(!i)return null;try{let u=await(n.importModule??(d=>import(d)))(i);try{Eo(a,u)}catch(d){l.debug("hydrate: cache set failed",d)}return u}catch(u){return l.debug("hydrate: failed to import module",{moduleUrl:i,error:u}),await(n.recoverSnapshotFailure??X)(i),null}}function So(e,t,r,n){if(t.moduleUrl)return B(t.moduleUrl,e.dependencyPinningCacheKey);if(!t.rel)return null;let i=e.graphIds?.client.find(s=>s.rel===t.rel)?.path;return G({strategy:r,rel:t.rel,absPath:i,version:e.hash,dependencyPinningCacheKey:e.dependencyPinningCacheKey,releaseAssetModules:n})}function Co(e){let t=Array.from(e.querySelectorAll("[data-client-ref]")),r=new Set(t);return t.filter(n=>{let i=n.parentElement;for(;i;){if(r.has(i))return!1;i=i.parentElement}return!0})}async function nt(e=document){let t=null;try{t=await To(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=Co(e);try{let c=globalThis.__VF_MANIFEST_HASH;if(!r.some(f=>f.dataset?.hydrated!=="true")&&c&&t.hash&&c===t.hash)return}catch(c){l.debug("hydrate: hmr hash read failed",c)}if(r.length===0){try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed",c)}return}let n=S(e),i=F(n),s=n?.releaseAssetModules;try{if(globalThis.__VF_TEST_MODE__){globalThis.__VF_HYDRATE_CALLED=!0,globalThis.__VF_MANIFEST_HASH=t.hash??"";return}}catch(c){l.debug("hydrate: test mode flags failed",c)}let a=j(e,n?.reactVersion),[{default:u},{createRoot:d}]=await Promise.all([import(a.react),import(a.reactDomClient)]);for(let c of r){let g=c.dataset?.clientRef??"";if(!g||c.dataset?.hydrated==="true")continue;let f=tt(g);if(!f)continue;let R=await rt(t,f,i,{releaseAssetModules:s});if(!R)continue;let P=R[f.exportName]??R.default;if(typeof P=="function")try{let h=d(c),J=Ro(c),b=ho(c),ot=await de(b,{Fragment:u.Fragment,createElement(H,Z,...U){return u.createElement(H,Z,...U)}},async H=>{let Z=t.modules.find(st=>st.id===H),U=t.components?.[H],ye=Z?.clientRef??(U?`${U}#default`:void 0);if(!ye)return null;let Q=tt(ye);if(!Q)return null;let ee=await rt(t,Q,i,{releaseAssetModules:s});if(!ee)return null;let me=ee[Q.exportName]??ee.default;return typeof me=="function"?me:null}),it=await W(u.createElement(P,J,...ot),n,e);h.render(it),c.dataset.hydrated="true"}catch(h){l.warn("hydrate: render failed",h)}}try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed (post)",c)}}var fe="data-vf-react-head-owner";var Ao=2*1024*1024,ea=Ao*2;var ta=64*1024,ra=1024*1024,na=1024*1024;var oa=new TextEncoder;async function bo(){let e=S(document),t=j(document,e?.reactVersion),[r,n]=await Promise.all([import(t.react),import(t.reactDomClient)]);return{React:r,ReactDOM:n}}var Oo=new Set(["SCRIPT","STYLE","NOSCRIPT","TEMPLATE"]);function pe(e){let t=e.getAttribute("style")??"";return e.hasAttribute("data-veryfront-head")||e.hasAttribute("hidden")||/(?:^|;)\\s*display\\s*:\\s*none(?:\\s*;|$)/i.test(t)||Oo.has(e.tagName.toUpperCase())}function Io(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function No(e,t){return e===t}function Do(e,t){let r=document.createElement("div");r.setAttribute("data-veryfront-hydration-root","page");let n=e.find(i=>!pe(i));n?.parentNode===t?t.insertBefore(r,n):t.appendChild(r);for(let i of e)!pe(i)&&i.parentNode===t&&r.appendChild(i);return r}function wo(e,t){for(let r of e){let n=[...r.hasAttribute(fe)?[r]:[],...r.querySelectorAll(`[${fe}]`)];for(let i of n)t.contains(i)||i.remove()}}function Mo(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function Lo(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function Po(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function Ho(e){return e==="rsc-module"}function Uo(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function vo(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function ko(e,t){try{let r=await fetch(I+"stream"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";if(!r.body)return"failure";let n=new AbortController;return addEventListener("pagehide",()=>n.abort(),{once:!0}),await Ze(r,document,n.signal),"success"}catch(r){return l.debug("tryStream failed",r),"failure"}}async function q(){try{await nt(document)}catch(e){l.debug("hydration failed",e)}}async function $o(e,t,r){try{let{React:n,ReactDOM:i}=await bo(),s=vo(e,t,r);if(!s)return!1;l.debug("Loading component from:",s);let a;try{a=await import(s)}catch(R){throw await X(s),R}let u=a.default;if(typeof u!="function")return l.debug("Page component is not a function"),!1;let d=Array.from(document.body.children),c=Io(d,document.body),g=No(c,document.body)?Do(d,document.body):c;wo(d,g);let f=await W(n.createElement(u,{}),r);return Ho(t)?i.createRoot(g).render(f):i.hydrateRoot(g,f,{identifierPrefix:"vf",onRecoverableError:()=>{}}),l.debug("Page component hydrated successfully"),!0}catch(n){return l.error("Page hydration failed",n),!1}}async function Vo(e,t){try{let r=await fetch(I+"payload"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";let n=await r.json();if(V(document,n?.dependencyPinningCacheKey),n?.slots){for(let[i,s]of Object.entries(n.slots))L(document,i).innerHTML=M(String(s||""));return"success"}return L(document,N).innerHTML=M(String(n?.html||"")),"success"}catch(r){return l.debug("payload fetch failed",r),"failure"}}async function Fo(){try{let e=S(document),t=Uo(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(Po()){await q();return}let r=e?.pagePath,n=F(e);if(r){if(Mo(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await $o(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!Lo(document,e))return;let i=await ko(t,e);if(i==="snapshot-conflict")return;if(i==="success"){await q();return}let s=await Vo(t,e);if(s==="snapshot-conflict")return;if(s==="success"){await q();return}await q()}catch(e){l.error("boot failed",e)}}if(typeof document<"u"){let e=()=>{Fo()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{Fo as boot,vo as buildPageHydrationModuleUrl,Uo as buildRSCTransportQuery,wo as retireAbandonedHeadOwnerMarkers,Io as selectHydrationRoot,Lo as shouldAttemptRSCTransport,Po as shouldHydrateOnly,Ho as shouldRenderPageComponent,Mo as shouldUsePageRendererHydration,No as shouldWrapPageHydrationRoot};\n'; export const CLIENT_DOM_BUNDLE: string = - 'var xe=Object.defineProperty;var _e=(t,r,e)=>r in t?xe(t,r,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[r]=e;var E=(t,r,e)=>_e(t,typeof r!="symbol"?r+"":r,e);var he=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Se(t,...r){let e=Object.create(null),o=t.charAt(0).toUpperCase()+t.slice(1);for(let s of r)for(let[i,a]of Object.entries(s)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${o} entry "${i}" must define a slug`);let c=a.slug;if(typeof c!="string")throw new Error(`${o} entry "${i}" must define a string slug`);if(c!==i)throw new Error(`${o} key "${i}" does not match entry slug "${c}"`);if(Object.hasOwn(e,i))throw new Error(`Duplicate ${t} slug "${i}"`);e[i]=a}return Object.freeze(e)}function P(...t){for(let r of t)for(let e of Object.values(r)){if(typeof e.slug!="string"||e.slug.length<3||e.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(e.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${e.slug}"`);if(typeof e.category!="string"||!he.has(e.category))throw new TypeError(`Registered error has unknown category "${e.category}"`);if(!Number.isInteger(e.status)||e.status<400||e.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${e.status}`);if(typeof e.title!="string"||e.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(e.suggestion!==void 0&&(typeof e.suggestion!="string"||e.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Se("error registry",...t)}var I={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},an={debug:I.gray,info:I.green,warn:I.yellow,error:I.red};var p="[REDACTED]",m=Reflect.apply;var $=RegExp.prototype.exec,y=RegExp.prototype[Symbol.replace],un=String.prototype.charCodeAt,k=String.prototype.slice,Ie=String.prototype.toLowerCase,Oe=/[^a-z0-9]/g;function D(t){let r=m(Ie,t,[]);return m(y,Oe,[r,""])}function O(t,r,e){return e===void 0?m(k,t,[r]):m(k,t,[r,e])}var Te=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],Ce=512,Ne=128,S=new Map;function F(t){let r=t.length<=Ne;if(r){let s=S.get(t);if(s!==void 0)return s}let e=D(t),o=Te.some(s=>e.includes(s));if(r){if(S.size>=Ce){let s=S.keys().next().value;s!==void 0&&S.delete(s)}S.set(t,o)}return o}var Ae=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],De=new Set(Ae.map(D)),be=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Le=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Ue=3;function we(t){return t===" "||t==="\t"||t===","||t===";"||t==="&"||t==="?"||t==="#"}function ve(t){if(!t)return!1;let r=t.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function H(t){return ve(t)||t==="_"||t==="$"}function Me(t){if(!t)return!1;let r=t.charCodeAt(0);return H(t)||r>=48&&r<=57||t==="."||t==="-"}function j(t,r){let e=r,o=t[e]===\'"\'||t[e]==="\'"?t[e++]:"";if(!H(t[e]))return!1;for(e++;Me(t[e]);)e++;if(o){if(t[e]!==o)return!1;e++}for(;t[e]===" "||t[e]==="\t";)e++;return t[e]===":"||t[e]==="="}function z(t){return t==="\\r"||t===`\n`||t==="}"||t==="]"||we(t)}function Y(t,r){let e=r;for(;e=t.length||j(t,e)}function Pe(t,r){let e=r,o=!0;if(t.startsWith(p,r)){let g=r+p.length;if(V(t,g))return{end:g,replacement:p};e=g,o=!1}let s=o&&(t[e]===\'"\'||t[e]==="\'"||t[e]==="`")?t[e]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=e;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:t.length,replacement:a()};if(c.pop(),g++,c.length===0&&V(t,g))return{end:g,replacement:a()};continue}if(c.length>0||!z(f)){g++;continue}let h=g;if(g=Y(t,g),g>=t.length||j(t,g))return{end:h,replacement:a()}}return{end:t.length,replacement:a()}}function G(t,r,e,o){let s=0,i="";for(let a=m($,r,[t]);a;a=m($,r,[t])){let c=a[e];if(!F(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&t.startsWith(p,d)&&t[g]==="#")continue;let f=Pe(t,d);i+=O(t,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?t:i+O(t,s)}function $e(t,r,e){let o=e.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${O(e,0,o)}`,i=t==="//"?`https://${s}`:`${t}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function ke(t){let r=t;for(let e=0;e{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=O(s,0,i);return`${o}${a}:${p}@`}]);return r=m(y,Le,[r,(e,o,s,i)=>$e(o,s,i)?e:`${o}${s}:${p}@`]),r=m(y,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(e,o,s,i)=>{let a=ke(s);return De.has(D(a))||F(a)?`${o}${s}=${p}`:e}]),r=m(y,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=m(y,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(e,o)=>`${o}${p}`]),r=m(y,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=G(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=G(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var Ve=2048;var pn=64*1024,Ge=256,Fe="https://veryfront.com/docs/errors/",B="...[truncated]",L="unknown-error";function W(t,r){if(t.length<=r)return t;let e=Math.max(0,r-B.length);return`${He(t,e)}${B}`}function He(t,r){let e=t.slice(0,r),o=e.charCodeAt(e.length-1);return o>=55296&&o<=56319&&(e=e.slice(0,-1)),e}function je(t){let r="";for(let e=0;e=55296&&o<=56319){let s=t.charCodeAt(e+1);s>=56320&&s<=57343?(r+=t.slice(e,e+2),e++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":t.charAt(e)}return r}function x(t){return typeof t!="string"?p:W(b(t),Ve)}function ze(t){let r=typeof t=="string"?b(t):L,e=W(r||L,Ge),o=je(e);return o==="."||o===".."?L:o}function T(t){let r=encodeURIComponent(ze(t));return`${Fe}${r}`}var Ye=Object.freeze,Be=Object.getOwnPropertyDescriptors,K=Number.isFinite,X=new WeakSet,We=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(t){let r={...t},e={...r,create(o){let s=o?.message,i=o?.detail,a=o?.cause,c=o?.instance,d=o?.context,u=o?.status??r.status;return new U(s||i||r.title,{slug:r.slug,category:r.category,status:u,title:r.title,suggestion:r.suggestion,exitCode:r.exitCode,detail:i,cause:a,instance:c,context:d})}};return Ye(e)}var U=class extends Error{constructor(e,o){super(e);E(this,"slug");E(this,"category");E(this,"status");E(this,"title");E(this,"suggestion");E(this,"exitCode");E(this,"detail");E(this,"cause");E(this,"instance");E(this,"context");X.add(this),this.name="VeryfrontError",this.slug=o.slug,this.category=o.category,this.status=o.status,this.title=o.title,this.suggestion=o.suggestion,this.exitCode=o.exitCode,this.detail=o.detail,this.cause=o.cause,this.instance=o.instance,this.context=o.context}toRFC9457(){let e=q(this);return e?{type:T(e.slug),title:x(e.title),status:e.status,detail:e.detail===void 0?void 0:x(e.detail),instance:e.instance===void 0?void 0:x(e.instance),category:e.category,suggestion:e.suggestion===void 0?void 0:x(e.suggestion),cause:typeof e.cause=="string"?x(e.cause):void 0}:{type:T("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let e=q(this);return T(e?.slug??"unknown-error")}};function J(t){return typeof t=="object"&&t!==null&&X.has(t)}function q(t){return J(t)?Ke(t):null}function Ke(t){try{if(!J(t))return null;let r=Be(t),e=ye=>{let A=r[ye];return A&&"value"in A?A.value:void 0},o=e("slug"),s=e("category"),i=e("status"),a=e("title"),c=e("message"),d=e("suggestion"),u=e("exitCode"),g=e("detail"),f=e("cause"),h=e("instance"),Re=e("context"),N=e("stack");return typeof o!="string"||!We.has(s)||typeof i!="number"||!K(i)||typeof a!="string"||typeof c!="string"||d!==void 0&&typeof d!="string"||u!==void 0&&(typeof u!="number"||!K(u))||g!==void 0&&typeof g!="string"||h!==void 0&&typeof h!="string"||N!==void 0&&typeof N!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:h,context:Re,stack:N}}catch{return null}}var qe=n({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),Xe=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),Je=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),Ze=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),Qe=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),et=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),tt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),rt=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),nt=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),ot=n({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),st=n({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Z={"config-not-found":qe,"config-invalid":Xe,"config-parse-error":Je,"config-validation-error":Ze,"config-type-error":Qe,"import-map-invalid":et,"cors-config-invalid":tt,"config-validation-failed":rt,"webhook-config-invalid":nt,"schedule-config-invalid":ot,"trigger-config-invalid":st};var it=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),at=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),ct=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),ut=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),lt=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),gt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),dt=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),ft=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Q={"build-failed":it,"bundle-error":at,"typescript-error":ct,"mdx-compile-error":ut,"asset-optimization-error":lt,"ssg-generation-error":gt,"sourcemap-error":dt,"compilation-error":ft};var pt=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Et=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),mt=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Rt=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),yt=n({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),xt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),_t=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),ht=n({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),St=n({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),It=n({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),ee={"hydration-mismatch":pt,"render-error":Et,"component-error":mt,"layout-not-found":Rt,"page-not-found":yt,"api-error":xt,"middleware-error":_t,"trigger-target-not-found":ht,"trigger-execution-failed":St,"trigger-not-supported":It};var Ot=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Tt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Ct=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Nt=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),At=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Dt=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),te={"route-conflict":Ot,"invalid-route-file":Tt,"route-handler-invalid":Ct,"dynamic-route-error":Nt,"route-params-error":At,"api-route-error":Dt};var bt=n({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Lt=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Ut=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),wt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),vt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Mt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),re={"module-not-found":bt,"import-resolution-error":Lt,"circular-dependency":Ut,"invalid-import":wt,"dependency-missing":vt,"version-mismatch":Mt};var Pt=n({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),$t=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),kt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),Vt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Gt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Ft=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Ht=n({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),jt=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),zt=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),Yt=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),Bt=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),Wt=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),Kt=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),qt=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),Xt=n({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),Jt=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ne={"port-in-use":Pt,"server-start-error":$t,"cache-error":kt,"file-watch-error":Vt,"request-error":Gt,"service-overloaded":Ft,"project-execution-unavailable":Ht,"semaphore-timeout":jt,"circuit-breaker-open":zt,"cache-path-mismatch":Yt,"network-error":Bt,"api-client-error":Wt,"token-storage-error":Kt,"cache-invariant-violation":qt,"release-not-found":Xt,"fallback-exhausted":Jt};var Zt=n({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),Qt=n({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),er=n({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),tr=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),rr=n({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),nr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),or=n({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),oe={"client-boundary-violation":Zt,"server-only-in-client":Qt,"client-only-in-server":er,"invalid-use-client":tr,"invalid-use-server":rr,"rsc-payload-error":nr,"ssr-output-limit-exceeded":or};var sr=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),ir=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),ar=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),cr=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),ur=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),se={"hmr-error":sr,"dev-server-error":ir,"fast-refresh-error":ar,"error-overlay-error":cr,"source-map-error":ur};var lr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),gr=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),dr=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),fr=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),pr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Er=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),mr=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Rr=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),yr=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),xr=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),_r=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),hr=n({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),ie={"deployment-error":lr,"platform-error":gr,"env-var-missing":dr,"production-build-required":fr,"environment-not-found":pr,"release-missing-version":Er,"release-build-timeout":mr,"deployment-verification-timeout":Rr,"push-receipt-missing":yr,"source-digest-mismatch":xr,"preview-hostname-too-long":_r,"branch-not-found":hr};var Sr=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Ir=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Or=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Tr=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Cr=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Nr=n({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Ar=n({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),ae={"agent-error":Sr,"agent-not-found":Ir,"agent-timeout":Or,"agent-intent-error":Tr,"orchestration-error":Cr,"cost-limit-exceeded":Nr,"tool-id-conflict":Ar};var Dr=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),br=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Lr=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Ur=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),wr=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),vr=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Mr=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),Pr=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),$r=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),w=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),kr=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Vr=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),ce={"unknown-error":Dr,"authentication-required":br,"permission-denied":Lr,"file-not-found":Ur,"resource-not-found":wr,"invalid-argument":vr,"timeout-error":Mr,"initialization-error":Pr,"not-supported":$r,"security-violation":w,"input-validation-failed":kr,"project-source-empty":Vr};var ro=P(Z,Q,ee,te,re,ne,oe,se,ie,ae,ce);var Gr=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Fr(){return Gr.map(({source:t,flags:r,name:e})=>({pattern:new RegExp(t,r),name:e}))}function Hr(){let t=globalThis;return t.__VERYFRONT_DEV__===!0||t.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function ue(t,r={}){let{allowInlineScripts:e=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of Fr())if(!(e&&a==="inline script")&&(i.lastIndex=0,!!i.test(t)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!Hr())))throw w.create({detail:`Potentially unsafe HTML: ${a} detected`});return t}var _=class{constructor(r,e){E(this,"prefix",r);E(this,"level",e)}log(r,e,o,...s){this.level>r||e?.(o,...s)}debug(r,...e){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...e)}info(r,...e){this.log(1,console.log,`[${this.prefix}] ${r}`,...e)}warn(r,...e){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...e)}error(r,...e){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...e)}};function jr(){if(typeof window>"u")return 2;let t=globalThis;return t.__VERYFRONT_DEV__||t.__RSC_DEV__?t.__VERYFRONT_DEBUG__||t.__RSC_DEBUG__?0:1:2}var C=jr(),R=new _("RSC",C),yo=new _("PREFETCH",C),xo=new _("HYDRATE",C),_o=new _("VERYFRONT",C);var Io=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var zr=5e3,Yr=1e4,Co=16*1024*1024,Br=5e3;var Wr=100;var Kr=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),No=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),Ao=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:zr,api:3e4,ssr:Yr,hmr:3e4,sandbox:Br}),cache:Object.freeze({jit:Object.freeze({maxSize:Wr,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:Kr})});var l="/_veryfront",v={RSC:`${l}/rsc/`,FS:`${l}/fs/`,MODULES:`${l}/modules/`,PAGES:`${l}/pages/`,DATA:`${l}/data/`,LIB:`${l}/lib/`,CHUNKS:`${l}/chunks/`,CLIENT:`${l}/client/`},ge={HMR_RUNTIME:`${l}/hmr-runtime.js`,HMR:`${l}/hmr.js`,ERROR_OVERLAY:`${l}/error-overlay.js`,DEV_LOADER:`${l}/dev-loader.js`,CLIENT_LOG:`${l}/log`,CLIENT_JS:`${l}/client.js`,ROUTER_JS:`${l}/router.js`,PREFETCH_JS:`${l}/prefetch.js`,MANIFEST_JSON:`${l}/manifest.json`,APP_JS:`${l}/app.js`,RSC_CLIENT:`${l}/rsc/client.js`,RSC_MANIFEST:`${l}/rsc/manifest`,RSC_STREAM:`${l}/rsc/stream`,RSC_PAYLOAD:`${l}/rsc/payload`,RSC_RENDER:`${l}/rsc/render`,RSC_PAGE:`${l}/rsc/page`,RSC_MODULE:`${l}/rsc/module`,RSC_DOM:`${l}/rsc/dom.js`,LIB_CHAT_REACT:`${l}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${l}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${l}/lib/chat/primitives.js`};var qr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},bo=qr.CACHE;var Lo={HMR_RUNTIME:ge.HMR_RUNTIME,ERROR_OVERLAY:ge.ERROR_OVERLAY};var Xr=v.RSC,Jr=v.FS;var de="rsc-root",M="x-veryfront-dependency-pins";var Bo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});var Qr="veryfront-hydration-data";function fe(t){try{let r=[...t.querySelectorAll(`[id="${Qr}"]`)];if(r.length!==1)return null;let e=t.body;if(!e)return null;let o=r[0];return e.firstElementChild!==o&&o.parentElement!==e||o.tagName?.toLowerCase()!=="script"||o.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:o}catch{return null}}function pe(t,r){if(!r?.startsWith("on:"))return!1;try{let e=fe(t);if(!e)return!1;let o=JSON.parse(e.textContent||"{}");return o.dependencyPinningCacheKey=r,e.textContent=JSON.stringify(o),!0}catch(e){return R.debug("hydration dependency snapshot seed failed",e),!1}}function me(t,r){let e=r==="root"?de:`rsc-slot-${r}`,o=t.getElementById(e);if(o)return o;let s=t.createElement("div");return s.id=e,t.body.appendChild(s),s}function en(t,r){if(r.type!=="slot")return;let e=me(t,r.id);e.innerHTML=ue(String(r.html??""))}function Ee(t,r){let e=r.split(`\n`),o=e.pop()??"";for(let s of e){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){R.debug("[client-dom] malformed NDJSON line",{line:i,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let c=a;if(c.type==="slot"){en(t,c);try{nn(t,c.id||"root")}catch(d){R.debug("[client-dom] hydration optional failed",d)}}}return o}function tn(t){return new Promise((r,e)=>{let o=()=>e(new DOMException("aborted","AbortError"));if(t.aborted){o();return}t.addEventListener("abort",o,{once:!0})})}async function ds(t,r=document,e){let o="body"in t?t:null,s=o?.body??t;if(!s)return;o&&pe(r,o.headers.get(M));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(e?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=e?await Promise.race([u,tn(e)]):await u;if(g){d=!0;break}c+=a.decode(f,{stream:!0}),c=Ee(r,c)}c&&Ee(r,`${c}\n`)}catch(u){throw u instanceof Error&&u.name==="AbortError"||R.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||R.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){R.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){R.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){R.debug("[client-dom] response.body.cancel failed",u)}}}function rn(t,r){let e=me(t,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(e),o}function nn(t,r){let e=rn(t,r);for(let o of e){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",R.debug("[client-dom] marked for hydration",s))}}export{ds as consumeNdjsonStream,me as getContainer};\n'; + 'var xe=Object.defineProperty;var _e=(t,r,e)=>r in t?xe(t,r,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[r]=e;var E=(t,r,e)=>_e(t,typeof r!="symbol"?r+"":r,e);var he=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Se(t,...r){let e=Object.create(null),o=t.charAt(0).toUpperCase()+t.slice(1);for(let s of r)for(let[i,a]of Object.entries(s)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${o} entry "${i}" must define a slug`);let c=a.slug;if(typeof c!="string")throw new Error(`${o} entry "${i}" must define a string slug`);if(c!==i)throw new Error(`${o} key "${i}" does not match entry slug "${c}"`);if(Object.hasOwn(e,i))throw new Error(`Duplicate ${t} slug "${i}"`);e[i]=a}return Object.freeze(e)}function P(...t){for(let r of t)for(let e of Object.values(r)){if(typeof e.slug!="string"||e.slug.length<3||e.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(e.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${e.slug}"`);if(typeof e.category!="string"||!he.has(e.category))throw new TypeError(`Registered error has unknown category "${e.category}"`);if(!Number.isInteger(e.status)||e.status<400||e.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${e.status}`);if(typeof e.title!="string"||e.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(e.suggestion!==void 0&&(typeof e.suggestion!="string"||e.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Se("error registry",...t)}var I={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},un={debug:I.gray,info:I.green,warn:I.yellow,error:I.red};var p="[REDACTED]",m=Reflect.apply;var $=RegExp.prototype.exec,y=RegExp.prototype[Symbol.replace],gn=String.prototype.charCodeAt,k=String.prototype.slice,Ie=String.prototype.toLowerCase,Oe=/[^a-z0-9]/g;function D(t){let r=m(Ie,t,[]);return m(y,Oe,[r,""])}function O(t,r,e){return e===void 0?m(k,t,[r]):m(k,t,[r,e])}var Te=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],Ce=512,Ae=128,S=new Map;function F(t){let r=t.length<=Ae;if(r){let s=S.get(t);if(s!==void 0)return s}let e=D(t),o=Te.some(s=>e.includes(s));if(r){if(S.size>=Ce){let s=S.keys().next().value;s!==void 0&&S.delete(s)}S.set(t,o)}return o}var Ne=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],De=new Set(Ne.map(D)),be=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Le=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Ue=3;function we(t){return t===" "||t==="\t"||t===","||t===";"||t==="&"||t==="?"||t==="#"}function ve(t){if(!t)return!1;let r=t.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function H(t){return ve(t)||t==="_"||t==="$"}function Me(t){if(!t)return!1;let r=t.charCodeAt(0);return H(t)||r>=48&&r<=57||t==="."||t==="-"}function j(t,r){let e=r,o=t[e]===\'"\'||t[e]==="\'"?t[e++]:"";if(!H(t[e]))return!1;for(e++;Me(t[e]);)e++;if(o){if(t[e]!==o)return!1;e++}for(;t[e]===" "||t[e]==="\t";)e++;return t[e]===":"||t[e]==="="}function z(t){return t==="\\r"||t===`\n`||t==="}"||t==="]"||we(t)}function Y(t,r){let e=r;for(;e=t.length||j(t,e)}function Pe(t,r){let e=r,o=!0;if(t.startsWith(p,r)){let g=r+p.length;if(V(t,g))return{end:g,replacement:p};e=g,o=!1}let s=o&&(t[e]===\'"\'||t[e]==="\'"||t[e]==="`")?t[e]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=e;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:t.length,replacement:a()};if(c.pop(),g++,c.length===0&&V(t,g))return{end:g,replacement:a()};continue}if(c.length>0||!z(f)){g++;continue}let h=g;if(g=Y(t,g),g>=t.length||j(t,g))return{end:h,replacement:a()}}return{end:t.length,replacement:a()}}function G(t,r,e,o){let s=0,i="";for(let a=m($,r,[t]);a;a=m($,r,[t])){let c=a[e];if(!F(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&t.startsWith(p,d)&&t[g]==="#")continue;let f=Pe(t,d);i+=O(t,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?t:i+O(t,s)}function $e(t,r,e){let o=e.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${O(e,0,o)}`,i=t==="//"?`https://${s}`:`${t}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function ke(t){let r=t;for(let e=0;e{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=O(s,0,i);return`${o}${a}:${p}@`}]);return r=m(y,Le,[r,(e,o,s,i)=>$e(o,s,i)?e:`${o}${s}:${p}@`]),r=m(y,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(e,o,s,i)=>{let a=ke(s);return De.has(D(a))||F(a)?`${o}${s}=${p}`:e}]),r=m(y,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=m(y,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(e,o)=>`${o}${p}`]),r=m(y,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=G(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=G(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var Ve=2048;var mn=64*1024,Ge=256,Fe="https://veryfront.com/docs/errors/",B="...[truncated]",L="unknown-error";function W(t,r){if(t.length<=r)return t;let e=Math.max(0,r-B.length);return`${He(t,e)}${B}`}function He(t,r){let e=t.slice(0,r),o=e.charCodeAt(e.length-1);return o>=55296&&o<=56319&&(e=e.slice(0,-1)),e}function je(t){let r="";for(let e=0;e=55296&&o<=56319){let s=t.charCodeAt(e+1);s>=56320&&s<=57343?(r+=t.slice(e,e+2),e++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":t.charAt(e)}return r}function x(t){return typeof t!="string"?p:W(b(t),Ve)}function ze(t){let r=typeof t=="string"?b(t):L,e=W(r||L,Ge),o=je(e);return o==="."||o===".."?L:o}function T(t){let r=encodeURIComponent(ze(t));return`${Fe}${r}`}var Ye=Object.freeze,Be=Object.getOwnPropertyDescriptors,K=Number.isFinite,X=new WeakSet,We=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(t){let r={...t},e={...r,create(o){let s=o?.message,i=o?.detail,a=o?.cause,c=o?.instance,d=o?.context,u=o?.status??r.status;return new U(s||i||r.title,{slug:r.slug,category:r.category,status:u,title:r.title,suggestion:r.suggestion,exitCode:r.exitCode,detail:i,cause:a,instance:c,context:d})}};return Ye(e)}var U=class extends Error{constructor(e,o){super(e);E(this,"slug");E(this,"category");E(this,"status");E(this,"title");E(this,"suggestion");E(this,"exitCode");E(this,"detail");E(this,"cause");E(this,"instance");E(this,"context");X.add(this),this.name="VeryfrontError",this.slug=o.slug,this.category=o.category,this.status=o.status,this.title=o.title,this.suggestion=o.suggestion,this.exitCode=o.exitCode,this.detail=o.detail,this.cause=o.cause,this.instance=o.instance,this.context=o.context}toRFC9457(){let e=q(this);return e?{type:T(e.slug),title:x(e.title),status:e.status,detail:e.detail===void 0?void 0:x(e.detail),instance:e.instance===void 0?void 0:x(e.instance),category:e.category,suggestion:e.suggestion===void 0?void 0:x(e.suggestion),cause:typeof e.cause=="string"?x(e.cause):void 0}:{type:T("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let e=q(this);return T(e?.slug??"unknown-error")}};function J(t){return typeof t=="object"&&t!==null&&X.has(t)}function q(t){return J(t)?Ke(t):null}function Ke(t){try{if(!J(t))return null;let r=Be(t),e=ye=>{let N=r[ye];return N&&"value"in N?N.value:void 0},o=e("slug"),s=e("category"),i=e("status"),a=e("title"),c=e("message"),d=e("suggestion"),u=e("exitCode"),g=e("detail"),f=e("cause"),h=e("instance"),Re=e("context"),A=e("stack");return typeof o!="string"||!We.has(s)||typeof i!="number"||!K(i)||typeof a!="string"||typeof c!="string"||d!==void 0&&typeof d!="string"||u!==void 0&&(typeof u!="number"||!K(u))||g!==void 0&&typeof g!="string"||h!==void 0&&typeof h!="string"||A!==void 0&&typeof A!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:h,context:Re,stack:A}}catch{return null}}var qe=n({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),Xe=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),Je=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),Ze=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),Qe=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),et=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),tt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),rt=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),nt=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),ot=n({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),st=n({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Z={"config-not-found":qe,"config-invalid":Xe,"config-parse-error":Je,"config-validation-error":Ze,"config-type-error":Qe,"import-map-invalid":et,"cors-config-invalid":tt,"config-validation-failed":rt,"webhook-config-invalid":nt,"schedule-config-invalid":ot,"trigger-config-invalid":st};var it=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),at=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),ct=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),ut=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),lt=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),gt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),dt=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),ft=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Q={"build-failed":it,"bundle-error":at,"typescript-error":ct,"mdx-compile-error":ut,"asset-optimization-error":lt,"ssg-generation-error":gt,"sourcemap-error":dt,"compilation-error":ft};var pt=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Et=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),mt=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Rt=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),yt=n({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),xt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),_t=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),ht=n({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),St=n({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),It=n({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),ee={"hydration-mismatch":pt,"render-error":Et,"component-error":mt,"layout-not-found":Rt,"page-not-found":yt,"api-error":xt,"middleware-error":_t,"trigger-target-not-found":ht,"trigger-execution-failed":St,"trigger-not-supported":It};var Ot=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Tt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Ct=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),At=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),Nt=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Dt=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),te={"route-conflict":Ot,"invalid-route-file":Tt,"route-handler-invalid":Ct,"dynamic-route-error":At,"route-params-error":Nt,"api-route-error":Dt};var bt=n({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Lt=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Ut=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),wt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),vt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Mt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),re={"module-not-found":bt,"import-resolution-error":Lt,"circular-dependency":Ut,"invalid-import":wt,"dependency-missing":vt,"version-mismatch":Mt};var Pt=n({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),$t=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),kt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),Vt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Gt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Ft=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Ht=n({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),jt=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),zt=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),Yt=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),Bt=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),Wt=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),Kt=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),qt=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),Xt=n({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),Jt=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),Zt=n({slug:"rag-store-corrupt",category:"SERVER",status:500,title:"RAG store file is corrupt",suggestion:"Repair or move the store file aside, then retry; it was not overwritten"}),Qt=n({slug:"rag-store-unavailable",category:"SERVER",status:500,title:"RAG store file is unavailable",suggestion:"Check storage availability, permissions, and concurrent operations, then retry"}),ne={"port-in-use":Pt,"server-start-error":$t,"cache-error":kt,"file-watch-error":Vt,"request-error":Gt,"service-overloaded":Ft,"project-execution-unavailable":Ht,"semaphore-timeout":jt,"circuit-breaker-open":zt,"cache-path-mismatch":Yt,"network-error":Bt,"api-client-error":Wt,"token-storage-error":Kt,"cache-invariant-violation":qt,"release-not-found":Xt,"fallback-exhausted":Jt,"rag-store-corrupt":Zt,"rag-store-unavailable":Qt};var er=n({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),tr=n({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),rr=n({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),nr=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),or=n({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),sr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),ir=n({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),oe={"client-boundary-violation":er,"server-only-in-client":tr,"client-only-in-server":rr,"invalid-use-client":nr,"invalid-use-server":or,"rsc-payload-error":sr,"ssr-output-limit-exceeded":ir};var ar=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),cr=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),ur=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),lr=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),gr=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),se={"hmr-error":ar,"dev-server-error":cr,"fast-refresh-error":ur,"error-overlay-error":lr,"source-map-error":gr};var dr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),fr=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),pr=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),Er=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),mr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Rr=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),yr=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),xr=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),_r=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),hr=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Sr=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Ir=n({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),ie={"deployment-error":dr,"platform-error":fr,"env-var-missing":pr,"production-build-required":Er,"environment-not-found":mr,"release-missing-version":Rr,"release-build-timeout":yr,"deployment-verification-timeout":xr,"push-receipt-missing":_r,"source-digest-mismatch":hr,"preview-hostname-too-long":Sr,"branch-not-found":Ir};var Or=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Tr=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Cr=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Ar=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Nr=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Dr=n({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),br=n({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),ae={"agent-error":Or,"agent-not-found":Tr,"agent-timeout":Cr,"agent-intent-error":Ar,"orchestration-error":Nr,"cost-limit-exceeded":Dr,"tool-id-conflict":br};var Lr=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Ur=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),wr=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),vr=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Mr=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Pr=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),$r=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),kr=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Vr=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),w=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),Gr=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Fr=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),ce={"unknown-error":Lr,"authentication-required":Ur,"permission-denied":wr,"file-not-found":vr,"resource-not-found":Mr,"invalid-argument":Pr,"timeout-error":$r,"initialization-error":kr,"not-supported":Vr,"security-violation":w,"input-validation-failed":Gr,"project-source-empty":Fr};var oo=P(Z,Q,ee,te,re,ne,oe,se,ie,ae,ce);var Hr=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function jr(){return Hr.map(({source:t,flags:r,name:e})=>({pattern:new RegExp(t,r),name:e}))}function zr(){let t=globalThis;return t.__VERYFRONT_DEV__===!0||t.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function ue(t,r={}){let{allowInlineScripts:e=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of jr())if(!(e&&a==="inline script")&&(i.lastIndex=0,!!i.test(t)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!zr())))throw w.create({detail:`Potentially unsafe HTML: ${a} detected`});return t}var _=class{constructor(r,e){E(this,"prefix",r);E(this,"level",e)}log(r,e,o,...s){this.level>r||e?.(o,...s)}debug(r,...e){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...e)}info(r,...e){this.log(1,console.log,`[${this.prefix}] ${r}`,...e)}warn(r,...e){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...e)}error(r,...e){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...e)}};function Yr(){if(typeof window>"u")return 2;let t=globalThis;return t.__VERYFRONT_DEV__||t.__RSC_DEV__?t.__VERYFRONT_DEBUG__||t.__RSC_DEBUG__?0:1:2}var C=Yr(),R=new _("RSC",C),_o=new _("PREFETCH",C),ho=new _("HYDRATE",C),So=new _("VERYFRONT",C);var To=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Br=5e3,Wr=1e4,No=16*1024*1024,Kr=5e3;var qr=100;var Xr=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),Do=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),bo=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Br,api:3e4,ssr:Wr,hmr:3e4,sandbox:Kr}),cache:Object.freeze({jit:Object.freeze({maxSize:qr,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:Xr})});var l="/_veryfront",v={RSC:`${l}/rsc/`,FS:`${l}/fs/`,MODULES:`${l}/modules/`,PAGES:`${l}/pages/`,DATA:`${l}/data/`,LIB:`${l}/lib/`,CHUNKS:`${l}/chunks/`,CLIENT:`${l}/client/`},ge={HMR_RUNTIME:`${l}/hmr-runtime.js`,HMR:`${l}/hmr.js`,ERROR_OVERLAY:`${l}/error-overlay.js`,DEV_LOADER:`${l}/dev-loader.js`,CLIENT_LOG:`${l}/log`,CLIENT_JS:`${l}/client.js`,ROUTER_JS:`${l}/router.js`,PREFETCH_JS:`${l}/prefetch.js`,MANIFEST_JSON:`${l}/manifest.json`,APP_JS:`${l}/app.js`,RSC_CLIENT:`${l}/rsc/client.js`,RSC_MANIFEST:`${l}/rsc/manifest`,RSC_STREAM:`${l}/rsc/stream`,RSC_PAYLOAD:`${l}/rsc/payload`,RSC_RENDER:`${l}/rsc/render`,RSC_PAGE:`${l}/rsc/page`,RSC_MODULE:`${l}/rsc/module`,RSC_DOM:`${l}/rsc/dom.js`,LIB_CHAT_REACT:`${l}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${l}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${l}/lib/chat/primitives.js`};var Jr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},Uo=Jr.CACHE;var wo={HMR_RUNTIME:ge.HMR_RUNTIME,ERROR_OVERLAY:ge.ERROR_OVERLAY};var Zr=v.RSC,Qr=v.FS;var de="rsc-root",M="x-veryfront-dependency-pins";var Ko=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});var tn="veryfront-hydration-data";function fe(t){try{let r=[...t.querySelectorAll(`[id="${tn}"]`)];if(r.length!==1)return null;let e=t.body;if(!e)return null;let o=r[0];return e.firstElementChild!==o&&o.parentElement!==e||o.tagName?.toLowerCase()!=="script"||o.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:o}catch{return null}}function pe(t,r){if(!r?.startsWith("on:"))return!1;try{let e=fe(t);if(!e)return!1;let o=JSON.parse(e.textContent||"{}");return o.dependencyPinningCacheKey=r,e.textContent=JSON.stringify(o),!0}catch(e){return R.debug("hydration dependency snapshot seed failed",e),!1}}function me(t,r){let e=r==="root"?de:`rsc-slot-${r}`,o=t.getElementById(e);if(o)return o;let s=t.createElement("div");return s.id=e,t.body.appendChild(s),s}function rn(t,r){if(r.type!=="slot")return;let e=me(t,r.id);e.innerHTML=ue(String(r.html??""))}function Ee(t,r){let e=r.split(`\n`),o=e.pop()??"";for(let s of e){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){R.debug("[client-dom] malformed NDJSON line",{line:i,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let c=a;if(c.type==="slot"){rn(t,c);try{sn(t,c.id||"root")}catch(d){R.debug("[client-dom] hydration optional failed",d)}}}return o}function nn(t){return new Promise((r,e)=>{let o=()=>e(new DOMException("aborted","AbortError"));if(t.aborted){o();return}t.addEventListener("abort",o,{once:!0})})}async function ps(t,r=document,e){let o="body"in t?t:null,s=o?.body??t;if(!s)return;o&&pe(r,o.headers.get(M));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(e?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=e?await Promise.race([u,nn(e)]):await u;if(g){d=!0;break}c+=a.decode(f,{stream:!0}),c=Ee(r,c)}c&&Ee(r,`${c}\n`)}catch(u){throw u instanceof Error&&u.name==="AbortError"||R.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||R.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){R.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){R.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){R.debug("[client-dom] response.body.cancel failed",u)}}}function on(t,r){let e=me(t,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(e),o}function sn(t,r){let e=on(t,r);for(let o of e){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",R.debug("[client-dom] marked for hydration",s))}}export{ps as consumeNdjsonStream,me as getContainer};\n'; diff --git a/src/testing/mock-fetch.ts b/src/testing/mock-fetch.ts index 7e361b8183..f8c61faaa6 100644 --- a/src/testing/mock-fetch.ts +++ b/src/testing/mock-fetch.ts @@ -1,3 +1,5 @@ +import { __runWithOutboundFetchTransportForTests } from "#veryfront/security/http/outbound-fetch.ts"; + type FetchMock = typeof globalThis.fetch | undefined; /** Standard request-init fields that tests may need to observe from a fetch mock. */ @@ -63,7 +65,16 @@ export async function withMockFetch( }); try { - return await fn(); + if (typeof mockFetch !== "function") { + return await fn(); + } + return await __runWithOutboundFetchTransportForTests( + { + fetch: mockFetch, + pinnedFetch: (url, _addresses, init) => mockFetch(url, init), + }, + fn, + ); } finally { Object.defineProperty(globalThis, "fetch", { value: originalFetch, diff --git a/src/types/entities.ts b/src/types/entities.ts index f146d0e648..7b623ab19d 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -1,10 +1,11 @@ export interface Frontmatter { title?: string; description?: string; - layout?: string; + layout?: string | boolean; tags?: string[]; date?: string; published?: boolean; + isLayout?: boolean; [key: string]: string | number | boolean | string[] | undefined; } @@ -53,6 +54,128 @@ export interface EntityTypeInfo { isPage: boolean; } +export function isFrontmatterRecord( + value: unknown, +): value is Record { + if (typeof value !== "object" || value === null) return false; + try { + const prototype = Object.getPrototypeOf(value); + return !Array.isArray(value) && + (prototype === Object.prototype || prototype === null); + } catch { + return false; + } +} + +/** + * Snapshot parsed frontmatter without invoking accessors, then remove values + * that violate the framework's known metadata fields. Unknown fields remain + * available to applications. + */ +export function normalizeFrontmatter(value: unknown): Frontmatter { + if (!isFrontmatterRecord(value)) return {}; + + const normalized: Frontmatter = {}; + try { + for ( + const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + ) + ) { + if (!descriptor.enumerable || !("value" in descriptor)) continue; + Object.defineProperty(normalized, key, { + configurable: true, + enumerable: true, + value: descriptor.value, + writable: true, + }); + } + } catch { + return {}; + } + + removeInvalidFrontmatterField( + normalized, + "title", + (entry) => typeof entry === "string", + ); + removeInvalidFrontmatterField( + normalized, + "description", + (entry) => typeof entry === "string", + ); + removeInvalidFrontmatterField( + normalized, + "layout", + (entry) => typeof entry === "string" || typeof entry === "boolean", + ); + normalizeFrontmatterTags(normalized); + normalizeFrontmatterDate(normalized); + removeInvalidFrontmatterField( + normalized, + "published", + (entry) => typeof entry === "boolean", + ); + removeInvalidFrontmatterField( + normalized, + "isLayout", + (entry) => typeof entry === "boolean", + ); + return normalized; +} + +function normalizeFrontmatterDate(frontmatter: Frontmatter): void { + const value = frontmatter.date; + if (value !== undefined && typeof value !== "string") delete frontmatter.date; +} + +function normalizeFrontmatterTags(frontmatter: Frontmatter): void { + const value = frontmatter.tags; + if (value === undefined) return; + + const tags = snapshotStringArray(value); + if (tags === null) { + delete frontmatter.tags; + return; + } + frontmatter.tags = tags; +} + +function snapshotStringArray(value: unknown): string[] | null { + try { + if (!Array.isArray(value)) return null; + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, "length"); + if (!lengthDescriptor || !("value" in lengthDescriptor)) return null; + const length = lengthDescriptor.value; + if ( + typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 + ) return null; + if (Reflect.ownKeys(value).length !== length + 1) return null; + + const snapshot: string[] = []; + for (let index = 0; index < length; index++) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index)); + if ( + !descriptor?.enumerable || !("value" in descriptor) || + typeof descriptor.value !== "string" + ) return null; + snapshot.push(descriptor.value); + } + return snapshot; + } catch { + return null; + } +} + +function removeInvalidFrontmatterField( + frontmatter: Frontmatter, + key: keyof Frontmatter, + isValid: (value: unknown) => boolean, +): void { + const value = frontmatter[key]; + if (value !== undefined && !isValid(value)) delete frontmatter[key]; +} + function detectFileKind(ext?: string): "mdx" | "tsx" | undefined { if (ext === "mdx") return "mdx"; if (ext === "tsx" || ext === "ts" || ext === "jsx" || ext === "js") return "tsx"; diff --git a/src/types/entities/getEntityInfo.ts b/src/types/entities/getEntityInfo.ts index 7dd599ea8d..4d2aa43630 100644 --- a/src/types/entities/getEntityInfo.ts +++ b/src/types/entities/getEntityInfo.ts @@ -1,377 +1,1033 @@ +/** Bounded page and layout entity discovery. @module types/entities/getEntityInfo */ + import { extract } from "#std/front-matter/yaml.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; import * as pathHelper from "#veryfront/compat/path"; -import { isExtendedFSAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; -import { detectEntityType } from "../entities.ts"; -import { createErrorScope } from "#veryfront/errors/error-context.ts"; -import { createError, toError } from "#veryfront/errors/veryfront-error.ts"; +import { detectEntityType, normalizeFrontmatter } from "../entities.ts"; import type { Entity, EntityInfo, Frontmatter } from "../entities.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; -import { withFallback } from "#veryfront/platform/adapters/fallback-wrapper.ts"; -import { parallelMap } from "#veryfront/utils/parallel.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { logger as baseLogger } from "#veryfront/utils"; +import { logger as baseLogger } from "#veryfront/utils/logger/index.ts"; +import { DEFAULT_MAX_FILE_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; +import { MAX_PATH_LENGTH_CHARS, MAX_ROUTE_SEGMENTS } from "#veryfront/utils/constants/limits.ts"; +import { + captureBoundedTextReader, + type CapturedBoundedTextReader, +} from "#veryfront/platform/adapters/bounded-text-reader.ts"; +import { + type CapturedSnapshotReader, + captureSnapshotReadCapability, +} from "#veryfront/platform/adapters/file-system-capabilities.ts"; +import { isFileSnapshotPathError } from "#veryfront/platform/adapters/file-snapshot-error.ts"; +import { + isNativeErrorWithoutHooks, + readNativeErrorNameWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; +import { + containsPathControlCharacters, + parseRouteParameterSegment, +} from "#veryfront/utils/route-path-utils.ts"; +import { + DYNAMIC_ROUTE_ERROR, + INVALID_ROUTE_FILE, + ROUTE_CONFLICT, +} from "#veryfront/errors/error-registry/route.ts"; const logger = baseLogger.component("get-entity-by-slug"); -const entityInfoScope = createErrorScope("getEntityInfo"); const fs = createFileSystem(); +const MAX_ENTITY_SOURCE_BYTES = DEFAULT_MAX_FILE_SIZE_BYTES; +const MAX_DIRECTORY_ENTRIES = 2_048; +const MAX_DYNAMIC_DIRECTORIES = 256; +const MAX_DYNAMIC_ENTRIES = 8_192; +const MAX_MATCHING_ROUTE_CANDIDATES = 32; +const MAX_PROTOTYPE_DEPTH = 64; +const strictTextDecoder = new TextDecoder("utf-8", { fatal: true }); +const DEFAULT_ENTITY_RESOLUTION_TIMEOUT_MS = 30_000; +const MAX_ACTIVE_PROJECT_RESOLUTIONS = 4; +const MAX_QUEUED_PROJECT_RESOLUTIONS = 16; +const MAX_ACTIVE_GLOBAL_RESOLUTIONS = 16; +const MAX_QUEUED_GLOBAL_RESOLUTIONS = 64; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const dateNow = Date.now; +const setTimer = setTimeout; +const clearTimer = clearTimeout; const PAGE_FILE_EXTENSIONS = ["mdx", "md", "tsx", "jsx", "ts", "js"] as const; const DIRECT_ROUTE_EXTENSIONS = PAGE_FILE_EXTENSIONS; const LAYOUT_FILE_EXTENSIONS = ["mdx", "md", "tsx", "jsx", "ts", "js"] as const; -const DYNAMIC_PAGE_ENTRY_PATTERN = /\[.+\]\.(mdx|md|tsx|jsx|ts|js)$/; -const OPTIONAL_CATCH_ALL_ENTRY_PATTERN = /\[\[\.\.\..+\]\]\.(mdx|md|tsx|jsx|ts|js)$/; +const SUPPORTED_PAGE_EXTENSION_PATTERN = /\.(mdx|md|tsx|jsx|ts|js)$/i; +const SUPPORTED_PAGE_SUFFIX_PATTERN = /^\.(mdx|md|tsx|jsx|ts|js)$/i; -type DirectoryEntry = { name: string; isFile: boolean; isDirectory: boolean }; +/** @internal Immutable directory-entry snapshot used during route discovery. */ +export interface EntityResolutionDirectoryEntry { + readonly name: string; + readonly isFile: boolean; + readonly isDirectory: boolean; +} +type DirectoryEntry = EntityResolutionDirectoryEntry; +type EntityCandidate = { path: string; root: string; virtualRoot: string }; +type DynamicTraversalBudget = { directoriesVisited: number; entriesInspected: number }; -export async function getEntityInfo( - filePath: string, - adapter?: RuntimeAdapter, -): Promise { - return await withSpan( - "types.getEntityInfo", - async () => { - // Normalize path for Veryfront API adapter - let normalizedPath = filePath; - if (adapter) { - const adapterFs = adapter.fs; - if (isExtendedFSAdapter(adapterFs) && adapterFs.isVeryfrontAdapter()) { - // API adapter needs relative paths, not absolute paths. - // Match the first known entity directory to find where the project-relative path starts. - // NOTE: "app" is intentionally excluded from the capture group because the container - // project dir ("/app/") would be incorrectly matched as the "app" entity directory, - // producing paths like "app/components/..." instead of "components/...". - // The adapter's PathNormalizer handles stripping the absolute prefix correctly. - normalizedPath = filePath.replace( - /^.*?\/(pages|components|layouts)\//, - "$1/", - ); +export interface EntityResolutionOptions { + /** Caller cancellation for this one page or layout lookup. */ + readonly signal?: AbortSignal; + /** Absolute Unix timestamp in milliseconds for this lookup. */ + readonly deadline?: number; + /** Stable tenant/project identity used only for resolution admission isolation. */ + readonly scopeKey?: string; +} + +export interface EntityInfoOptions extends EntityResolutionOptions { + /** Explicit directory from which an index page slug is derived. */ + readonly routeRoot?: string; +} + +interface CapturedMethod { + invoke(...args: Args): Result; +} + +interface CapturedEntityReadAuthority { + readonly bounded: CapturedBoundedTextReader; + readonly snapshot?: CapturedSnapshotReader; + readonly symlinkFree: boolean; + readonly readDir?: CapturedMethod<[string], AsyncIterable>; + readonly resolveFile?: CapturedMethod<[string], Promise>; + readonly resolveEntityId?: CapturedMethod<[string], unknown>; +} + +/** @internal Cancellation-aware operations shared by framework entity resolvers. */ +export interface EntityResolutionGate { + throwIfCancelled(): void; + awaitOperation(operation: () => Promise): Promise; +} + +/** @internal Captured route-filesystem operations for one admitted lookup. */ +export interface EntityResolutionSession extends EntityResolutionGate { + readonly hasResolveFile: boolean; + resolveFile(path: string): Promise; + readDirectory(path: string): Promise; + readEntityWithinRoot( + filePath: string, + rootDir: string, + virtualRoot?: string, + ): Promise; +} + +interface ResolutionLifecycle extends EntityResolutionGate { + readonly signal?: AbortSignal; + readonly deadline: number; + waitForPendingOperations(): Promise; +} + +interface ResolutionContext extends ResolutionLifecycle { + readonly authority: CapturedEntityReadAuthority; +} + +interface ProjectLaneRegistration { + readonly lane: ResolutionLane; + cleanup(): void; +} + +interface LaneWaiter { + readonly context: ResolutionLifecycle; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; + abortListener?: () => void; + timer?: ReturnType; +} + +class ResolutionLane { + #active = 0; + readonly #waiters: LaneWaiter[] = []; + + constructor( + private readonly maxActive: number, + private readonly maxQueued: number, + private readonly queueLabel: string, + ) {} + + get idle(): boolean { + return this.#active === 0 && this.#waiters.length === 0; + } + + async acquire(context: ResolutionLifecycle): Promise<() => void> { + context.throwIfCancelled(); + if (this.#active < this.maxActive) { + this.#active++; + return createOnceRelease(() => this.#release()); + } + if (this.#waiters.length >= this.maxQueued) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: `${this.queueLabel} exceeds the ${this.maxQueued}-request limit`, + }); + } + + await new Promise((resolve, reject) => { + const waiter: LaneWaiter = { context, resolve, reject }; + const removeAndReject = (reason: unknown): void => { + const index = this.#waiters.indexOf(waiter); + if (index === -1) return; + this.#waiters.splice(index, 1); + cleanupLaneWaiter(waiter); + reject(reason); + }; + + const armDeadline = (): void => { + const remainingMs = context.deadline - dateNow(); + if (remainingMs <= 0) { + removeAndReject(createResolutionTimeoutError()); + return; } + waiter.timer = setTimer( + armDeadline, + Math.min(remainingMs, MAX_TIMER_DELAY_MS), + ); + }; + if (context.signal) { + waiter.abortListener = () => removeAndReject(getAbortReason(context.signal!)); + context.signal.addEventListener("abort", waiter.abortListener, { once: true }); } + this.#waiters.push(waiter); + if (context.signal?.aborted) { + removeAndReject(getAbortReason(context.signal)); + } else { + armDeadline(); + } + }); - try { - const shouldReadDirectly = adapter - ? isExtendedFSAdapter(adapter.fs) && adapter.fs.isVeryfrontAdapter() - : false; - - let content: string; - if (adapter) { - if (!shouldReadDirectly) { - try { - const stat = await withFallback( - () => adapter.fs.stat(normalizedPath), - async () => { - const exists = await fs.exists(filePath); - if (!exists) { - throw toError( - createError({ - type: "file", - message: "File not found", - context: { path: filePath, operation: "read" }, - }), - ); - } - return await fs.stat(filePath); - }, - { operationName: "stat:getEntityInfo", logError: false }, - ); + try { + context.throwIfCancelled(); + } catch (error) { + this.#release(); + throw error; + } + return createOnceRelease(() => this.#release()); + } - if (!stat.isFile) return null; - } catch (error) { - entityInfoScope.runSync( - () => { - throw error; - }, - { path: filePath, details: { reason: "stat-failed" } }, - undefined, - ); - return null; - } - } + #release(): void { + const waiter = this.#waiters.shift(); + if (waiter) { + cleanupLaneWaiter(waiter); + waiter.resolve(); + return; + } + this.#active--; + } +} - content = await withFallback( - () => adapter.fs.readFile(normalizedPath), - () => fs.readTextFile(filePath), - { operationName: "readFile:getEntityInfo", logError: false }, - ); - } else { - const exists = await fs.exists(filePath); - if (!exists) return null; - content = await fs.readTextFile(filePath); - } +class ResolutionCapacity { + #admitted = 0; - const ext = pathHelper.extname(filePath).toLowerCase(); + acquire(context: ResolutionLifecycle): () => void { + context.throwIfCancelled(); + const maxAdmitted = MAX_ACTIVE_GLOBAL_RESOLUTIONS + MAX_QUEUED_GLOBAL_RESOLUTIONS; + if (this.#admitted >= maxAdmitted) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: + `Global route resolution capacity exceeds the ${MAX_ACTIVE_GLOBAL_RESOLUTIONS}-active/${MAX_QUEUED_GLOBAL_RESOLUTIONS}-queued limit`, + }); + } + this.#admitted++; + return createOnceRelease(() => { + this.#admitted--; + }); + } +} - let frontmatter: Frontmatter = {}; - let body = content; +const localProjectLanes = new Map(); +const adapterProjectLanes = new WeakMap>(); +const globalResolutionLane = new ResolutionLane( + MAX_ACTIVE_GLOBAL_RESOLUTIONS, + MAX_QUEUED_GLOBAL_RESOLUTIONS, + "Global route resolution queue", +); +const globalResolutionCapacity = new ResolutionCapacity(); - if (ext === ".md" || ext === ".mdx") { - try { - const extracted = extract(content); - frontmatter = extracted.attrs as Frontmatter; - body = extracted.body; - } catch (_) { - /* expected: malformed YAML frontmatter */ - } - } +function isFileNotFoundError(error: unknown): boolean { + return isCanonicalNotFoundError(error); +} - const fileName = filePath.split("/").pop() ?? ""; - const { type, kind, isLayout, isComponent, isPage } = detectEntityType( - fileName, - frontmatter, - ); +/** + * Classify the native error produced when the captured local filesystem reads + * a directory as a file. This intentionally applies only to the framework's + * own local filesystem: adapter errors remain operational failures and are + * never converted to ordinary absence based on an error-shaped value. + */ +function isLocalDirectoryReadError( + error: unknown, + adapter: RuntimeAdapter | undefined, +): boolean { + if (adapter !== undefined || !isNativeErrorWithoutHooks(error)) return false; + if (readNativeErrorNameWithoutHooks(error) === "IsADirectory") return true; - let entityId = filePath; - if (adapter) { - try { - const adapterFs = adapter.fs; - if (isExtendedFSAdapter(adapterFs) && adapterFs.isVeryfrontAdapter()) { - const underlyingAdapter = adapterFs.getUnderlyingAdapter(); - - if ( - underlyingAdapter && - "getEntityIdForPath" in underlyingAdapter && - typeof underlyingAdapter.getEntityIdForPath === "function" - ) { - const getEntityIdForPath = underlyingAdapter.getEntityIdForPath as ( - path: string, - ) => string | undefined; - const relativePath = filePath - .replace(/^.*?\/pages\//, "pages/") - .replace(/^.*?\/components\//, "components/"); - entityId = getEntityIdForPath(relativePath) ?? entityId; - } - } - } catch (_) { - /* expected: entity ID extraction may fail, fall back to file path */ - } - } + try { + const code = Reflect.getOwnPropertyDescriptor(error, "code"); + return code !== undefined && "value" in code && code.value === "EISDIR"; + } catch { + return false; + } +} - const entity: Entity = { - id: entityId, - path: filePath, - slug: getSlugFromPath(filePath), - type, - content: body, - frontmatter, - kind, - isLayout, - isComponent, - isPage, - }; - - return { entity }; - } catch (error) { - entityInfoScope.runSync( - () => { - throw error; - }, - { path: filePath, details: { reason: "entity-info-failed" } }, - undefined, +function createOnceRelease(release: () => void): () => void { + let released = false; + return () => { + if (released) return; + released = true; + release(); + }; +} + +function cleanupLaneWaiter(waiter: LaneWaiter): void { + if (waiter.timer !== undefined) clearTimer(waiter.timer); + if (waiter.abortListener && waiter.context.signal) { + waiter.context.signal.removeEventListener("abort", waiter.abortListener); + } +} + +function createResolutionTimeoutError(): Error { + return new DOMException("Entity resolution exceeded its deadline", "TimeoutError"); +} + +function getAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("Entity resolution was aborted", "AbortError"); +} + +function createResolutionLifecycle( + options: EntityResolutionOptions = {}, +): ResolutionLifecycle { + const requestedDeadline = options.deadline; + if ( + requestedDeadline !== undefined && + (!Number.isFinite(requestedDeadline) || requestedDeadline < 0) + ) { + throw new TypeError("Entity resolution deadline must be a finite non-negative timestamp"); + } + + const signal = options.signal; + const deadline = requestedDeadline ?? dateNow() + DEFAULT_ENTITY_RESOLUTION_TIMEOUT_MS; + const pendingOperations = new Set>(); + const throwIfCancelled = (): void => { + if (signal?.aborted) throw getAbortReason(signal); + if (dateNow() >= deadline) throw createResolutionTimeoutError(); + }; + + const trackOperation = (operation: Promise): Promise => { + pendingOperations.add(operation); + void operation.then( + () => pendingOperations.delete(operation), + () => pendingOperations.delete(operation), + ); + return operation; + }; + + const awaitOperation = async (operation: () => Promise): Promise => { + throwIfCancelled(); + const activeOperation = trackOperation(Promise.resolve().then(operation)); + + let abortListener: (() => void) | undefined; + let deadlineTimer: ReturnType | undefined; + const cancellation = new Promise((_resolve, reject) => { + const rejectIfCancelled = (): boolean => { + if (signal?.aborted) { + reject(getAbortReason(signal)); + return true; + } + const remainingMs = deadline - dateNow(); + if (remainingMs <= 0) { + reject(createResolutionTimeoutError()); + return true; + } + deadlineTimer = setTimer( + rejectIfCancelled, + Math.min(remainingMs, MAX_TIMER_DELAY_MS), ); - return null; + return false; + }; + + if (signal) { + abortListener = () => reject(getAbortReason(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + } + if (rejectIfCancelled() && abortListener && signal) { + signal.removeEventListener("abort", abortListener); + abortListener = undefined; + } + }); + + try { + const result = await Promise.race([activeOperation, cancellation]); + throwIfCancelled(); + return result; + } finally { + if (deadlineTimer !== undefined) clearTimer(deadlineTimer); + if (abortListener && signal) { + signal.removeEventListener("abort", abortListener); + } + } + }; + + return Object.freeze({ + signal, + deadline, + throwIfCancelled, + awaitOperation, + async waitForPendingOperations(): Promise { + while (pendingOperations.size > 0) { + await Promise.allSettled([...pendingOperations]); } }, - { "entity.path": filePath }, + }); +} + +function createResolutionContext( + adapter: RuntimeAdapter | undefined, + options: EntityResolutionOptions = {}, +): ResolutionContext { + const lifecycle = createResolutionLifecycle(options); + return Object.freeze({ + ...lifecycle, + authority: captureEntityReadAuthority(adapter), + }); +} + +function captureEntityReadAuthority( + adapter: RuntimeAdapter | undefined, +): CapturedEntityReadAuthority { + const fileSystem = adapter?.fs ?? fs; + const bounded = captureBoundedTextReader(fileSystem, "Route filesystem"); + const snapshot = captureSnapshotReadCapability( + fileSystem, + "Route filesystem", + true, + ); + const symlinkFree = adapter !== undefined && hasNoSymlinkSemantics(fileSystem); + const readDir = captureDataMethod<[string], AsyncIterable>( + fileSystem, + "readDir", ); + const resolveFile = adapter + ? captureDataMethod<[string], Promise>(fileSystem, "resolveFile") + : undefined; + const resolveEntityId = adapter ? captureEntityIdResolver(adapter) : undefined; + + return Object.freeze({ + bounded, + snapshot, + symlinkFree, + readDir, + resolveFile, + resolveEntityId, + }); } -export async function getEntityBySlug( +function captureEntityIdResolver( + adapter: RuntimeAdapter, +): CapturedMethod<[string], unknown> | undefined { + const isVeryfrontAdapter = captureDataMethod<[], unknown>( + adapter.fs, + "isVeryfrontAdapter", + ); + const getUnderlyingAdapter = captureDataMethod<[], unknown>( + adapter.fs, + "getUnderlyingAdapter", + ); + if ( + !isVeryfrontAdapter || !getUnderlyingAdapter || + isVeryfrontAdapter.invoke() !== true + ) return undefined; + + const underlyingAdapter = getUnderlyingAdapter.invoke(); + return captureDataMethod<[string], unknown>( + underlyingAdapter, + "getEntityIdForPath", + ); +} + +function captureDataMethod( + value: unknown, + key: string, +): CapturedMethod | undefined { + if ((typeof value !== "object" && typeof value !== "function") || value === null) { + return undefined; + } + + const receiver = value as object; + const visited = new Set(); + let current: object | null = receiver; + for (let depth = 0; current && depth < MAX_PROTOTYPE_DEPTH; depth++) { + if (current === Object.prototype) return undefined; + if (visited.has(current)) { + throw INVALID_ROUTE_FILE.create({ + detail: `Adapter ${key} has a cyclic prototype chain`, + }); + } + visited.add(current); + + const parent = Reflect.getPrototypeOf(current); + // A foreign realm's Object.prototype is terminal and never authority. + if (current !== receiver && parent === null) return undefined; + + const descriptor = Reflect.getOwnPropertyDescriptor(current, key); + if (descriptor) { + if (!("value" in descriptor) || descriptor.value === undefined) return undefined; + if (typeof descriptor.value !== "function") { + throw INVALID_ROUTE_FILE.create({ + detail: `Adapter ${key} must be a data-property method`, + }); + } + const method = descriptor.value as (...args: Args) => Result; + return Object.freeze({ + invoke: (...args: Args): Result => Reflect.apply(method, receiver, args), + }); + } + current = parent; + } + if (current !== null) { + throw INVALID_ROUTE_FILE.create({ + detail: `Adapter ${key} prototype chain is too deep`, + }); + } + return undefined; +} + +async function awaitResolution( + context: ResolutionContext, + operation: () => Promise, +): Promise { + return await context.awaitOperation(operation); +} + +function getProjectLaneRegistration( + projectScope: string, + adapter: RuntimeAdapter | undefined, +): ProjectLaneRegistration { + const projectKey = projectScope; + let lanes: Map; + if (adapter) { + const authorityKey = adapter.fs as object; + lanes = adapterProjectLanes.get(authorityKey) ?? new Map(); + if (!adapterProjectLanes.has(authorityKey)) { + adapterProjectLanes.set(authorityKey, lanes); + } + } else { + lanes = localProjectLanes; + } + + const lane = lanes.get(projectKey) ?? new ResolutionLane( + MAX_ACTIVE_PROJECT_RESOLUTIONS, + MAX_QUEUED_PROJECT_RESOLUTIONS, + "Project route resolution queue", + ); + if (!lanes.has(projectKey)) lanes.set(projectKey, lane); + return { + lane, + cleanup(): void { + if (lane.idle && lanes.get(projectKey) === lane) lanes.delete(projectKey); + }, + }; +} + +async function withProjectResolutionAdmission( + projectScope: string, + adapter: RuntimeAdapter | undefined, + context: ResolutionLifecycle, + operation: () => Promise, +): Promise { + const registration = getProjectLaneRegistration(projectScope, adapter); + let releaseCapacity: (() => void) | undefined; + let releaseProject: (() => void) | undefined; + let releaseGlobal: (() => void) | undefined; + try { + // Reserve bounded isolate capacity before entering a project lane. The + // project lane still comes before the global active lane, so one noisy + // tenant cannot occupy global execution permits while waiting on itself. + releaseCapacity = globalResolutionCapacity.acquire(context); + releaseProject = await registration.lane.acquire(context); + releaseGlobal = await globalResolutionLane.acquire(context); + } catch (error) { + releaseProject?.(); + releaseCapacity?.(); + registration.cleanup(); + throw error; + } + + const result = Promise.resolve().then(operation); + const releaseAfterUnderlyingWork = async (): Promise => { + // A caller receives its deadline or abort immediately. The permits remain + // held until any adapter promise that cannot be cancelled has actually + // settled, so reported cancellation never creates hidden overcommit. + await context.waitForPendingOperations(); + releaseGlobal?.(); + releaseProject?.(); + releaseCapacity?.(); + registration.cleanup(); + }; + void result.then(releaseAfterUnderlyingWork, releaseAfterUnderlyingWork); + return await result; +} + +function getResolutionScope( projectDir: string, - slug: string, + options: EntityResolutionOptions, +): string { + const scopeKey = options.scopeKey ?? projectDir; + if (!isBoundedIdentifier(scopeKey)) { + throw new TypeError("Entity resolution scope key must be a bounded non-empty string"); + } + return options.scopeKey === undefined + ? `path:${normalizeComparablePath(scopeKey)}` + : `scope:${scopeKey}`; +} + +/** + * Run an internal resolver under the same tenant and isolate-wide admission + * boundary used by Pages Router entity discovery. + * @internal + */ +export async function withEntityResolutionAdmission( + projectDir: string, + adapter: RuntimeAdapter, + options: EntityResolutionOptions, + operation: (session: EntityResolutionSession) => Promise, +): Promise { + const context = createResolutionContext(adapter, options); + const resolveFile = context.authority.resolveFile; + const session: EntityResolutionSession = Object.freeze({ + throwIfCancelled: context.throwIfCancelled, + awaitOperation: context.awaitOperation, + hasResolveFile: resolveFile !== undefined, + async resolveFile(path: string): Promise { + if (!resolveFile) return null; + const resolvedPath = await awaitResolution( + context, + () => resolveFile.invoke(path), + ); + if (resolvedPath === null) return null; + if (typeof resolvedPath !== "string" || !isBoundedPath(resolvedPath)) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an invalid resolved path", + }); + } + return resolvedPath; + }, + readDirectory(path: string): Promise { + return readDirectoryEntries(path, context); + }, + readEntityWithinRoot( + filePath: string, + rootDir: string, + virtualRoot = "", + ): Promise { + return getEntityInfoWithinRoot( + filePath, + rootDir, + adapter, + virtualRoot, + context, + ); + }, + }); + return await withProjectResolutionAdmission( + getResolutionScope(projectDir, options), + adapter, + context, + () => operation(session), + ); +} + +/** + * Reads and classifies one entity source file. + * + * Returns `null` when the source path does not identify a file. Adapter failures + * other than a missing path are propagated to the caller. + */ +export async function getEntityInfo( + filePath: string, adapter?: RuntimeAdapter, - pagesDirectory = "pages", + options: EntityInfoOptions = {}, ): Promise { + if (!isBoundedPath(filePath)) return null; + if (options.routeRoot !== undefined && !isBoundedPath(options.routeRoot)) return null; + const context = createResolutionContext(adapter, options); return await withSpan( - "types.getEntityBySlug", + "types.getEntityInfo", async () => { - const normalizedSlug = normalizeSlug(slug); - const isVeryfrontRoute = normalizedSlug.startsWith(".veryfront/") || - normalizedSlug === ".veryfront"; - const resolveFile = adapter?.fs.resolveFile; - - logger.debug("START", { - slug, - normalizedSlug, - projectDir, - isVeryfrontRoute, - hasResolveFile: !!resolveFile, + let source: { content: string; byteLength: number }; + try { + source = await awaitResolution( + context, + () => + context.authority.bounded.readUtf8( + filePath, + MAX_ENTITY_SOURCE_BYTES, + "Entity source", + ), + ); + } catch (error) { + if ( + isFileNotFoundError(error) || + isLocalDirectoryReadError(error, adapter) + ) return null; + throw error; + } + return createEntityInfo( + filePath, + source.content, + context.authority, + options.routeRoot, + ); + }, + { "entity.extension": pathHelper.extname(filePath).toLowerCase() }, + ); +} + +function createEntityInfo( + filePath: string, + content: string, + authority: CapturedEntityReadAuthority, + routeRoot?: string, +): EntityInfo { + const ext = pathHelper.extname(filePath).toLowerCase(); + + let frontmatter: Frontmatter = {}; + let body = content; + if (ext === ".md" || ext === ".mdx") { + try { + const extracted = extract(content); + frontmatter = normalizeFrontmatter(extracted.attrs); + body = extracted.body; + } catch { + /* expected: malformed YAML frontmatter */ + } + } + + const fileName = splitPathSegments(filePath).at(-1) ?? ""; + const { type, kind, isLayout, isComponent, isPage } = detectEntityType( + fileName, + frontmatter, + ); + + let entityId = filePath; + const resolvedEntityId = authority.resolveEntityId?.invoke(filePath); + if (resolvedEntityId !== undefined) { + if (!isBoundedIdentifier(resolvedEntityId)) { + throw INVALID_ROUTE_FILE.create({ + detail: "Entity identifier is invalid", }); + } + entityId = resolvedEntityId; + } - if (resolveFile) { - const basePaths = [pathHelper.join(projectDir, pagesDirectory, normalizedSlug)]; + const entity: Entity = { + id: entityId, + path: filePath, + slug: getSlugFromPath(filePath, routeRoot), + type, + content: body, + frontmatter, + kind, + isLayout, + isComponent, + isPage, + }; + return { entity }; +} - if (isVeryfrontRoute) basePaths.unshift(pathHelper.join(projectDir, normalizedSlug)); - if (normalizedSlug === "index" || normalizedSlug === "") { - basePaths.unshift(pathHelper.join(projectDir, pagesDirectory, "index")); - } +/** + * Resolves a page entity for a project-relative route slug. + * + * Resolution checks exact page files, directory index files, and dynamic route + * files without allowing candidates to escape the project root. + */ +export async function getEntityBySlug( + projectDir: string, + slug: string, + adapter?: RuntimeAdapter, + pagesDirectory = "pages", + options: EntityResolutionOptions = {}, +): Promise { + if ( + !isBoundedPath(projectDir) || + !isBoundedPath(slug) || + !isBoundedPath(pagesDirectory) + ) return null; - logger.debug("Checking paths (resolveFile branch)", { - slug, - normalizedSlug, - basePaths, - }); + const normalizedSlug = normalizeSlug(slug); + const routeSegmentCount = countPathSegments(normalizedSlug); + if ( + !isSafeRouteSlug(normalizedSlug) || + !isSafeProjectRelativePath(pagesDirectory) || + routeSegmentCount > MAX_ROUTE_SEGMENTS + ) return null; + + const context = createResolutionContext(adapter, options); + return await withProjectResolutionAdmission( + getResolutionScope(projectDir, options), + adapter, + context, + () => + withSpan( + "types.getEntityBySlug", + async () => { + context.throwIfCancelled(); + const isVeryfrontRoute = normalizedSlug.startsWith(".veryfront/") || + normalizedSlug === ".veryfront"; + const resolveFile = context.authority.resolveFile; + const pagesRoot = pathHelper.join(projectDir, pagesDirectory); + const pageStems = buildPageStems(normalizedSlug); - const candidateResults = await parallelMap(basePaths, async (basePath) => { - const resolvedPath = await resolveFile.call(adapter.fs, basePath); - logger.debug("resolveFile result", { - basePath, - resolvedPath, + logger.debug("Resolving page entity", { + routeSegmentCount, + isVeryfrontRoute, + hasResolveFile: !!resolveFile, }); - if (!resolvedPath) return null; - return await getEntityInfo(resolvedPath, adapter); - }); - for (const info of candidateResults) { - if (info?.entity.isPage) { - logger.debug("Found page via resolveFile", { - slug, - normalizedSlug, - path: info.entity.path, + if (resolveFile) { + const basePaths: EntityCandidate[] = pageStems.map((stem) => ({ + path: pathHelper.join(pagesRoot, stem), + root: projectDir, + virtualRoot: pagesDirectory, + })); + let directCandidateCount = 0; + + if (isVeryfrontRoute) { + basePaths.unshift({ + path: pathHelper.join(projectDir, normalizedSlug), + root: projectDir, + virtualRoot: ".veryfront", + }); + directCandidateCount = 1; + } + logger.debug("Resolving adapter page candidates", { + candidateCount: basePaths.length, }); - return withResolvedSlug(info, normalizedSlug); - } - } - const dynamicPage = await findDynamicPageEntity( - projectDir, - normalizedSlug, - adapter, - pagesDirectory, - ); - if (dynamicPage) return withResolvedSlug(dynamicPage, normalizedSlug); + const candidateGroups: EntityInfo[][] = []; + for (const candidate of basePaths) { + context.throwIfCancelled(); + candidateGroups.push( + isBoundedPath(candidate.path) + ? await resolveAdapterPageCandidate(candidate, adapter, context) + : [], + ); + } - logger.debug("No page found via resolveFile branch", { slug, normalizedSlug }); - return null; - } + if (directCandidateCount > 0) { + const directPage = selectPage( + candidateGroups.slice(0, directCandidateCount).flat().filter(isPageEntityInfo), + routeSegmentCount, + "exact", + ); + if (directPage) return withResolvedSlug(directPage, normalizedSlug); + } + const exactPage = selectPage( + candidateGroups.slice(directCandidateCount).flat().filter(isPageEntityInfo), + routeSegmentCount, + "exact", + ); + if (exactPage) { + logger.debug("Resolved page entity", { + routeSegmentCount, + }); + return withResolvedSlug(exactPage, normalizedSlug); + } - const candidatePaths = [ - ...buildFileCandidates( - projectDir, - [pagesDirectory], - normalizedSlug, - PAGE_FILE_EXTENSIONS, - ), - ...buildFileCandidates( - projectDir, - [pagesDirectory], - `${normalizedSlug}/index`, - PAGE_FILE_EXTENSIONS, - ), - ]; + const dynamicPage = await findDynamicPageEntity( + projectDir, + normalizedSlug, + adapter, + pagesDirectory, + context, + ); + if (dynamicPage) return withResolvedSlug(dynamicPage, normalizedSlug); - if (isVeryfrontRoute) { - candidatePaths.unshift( - ...buildFileCandidates(projectDir, [], normalizedSlug, DIRECT_ROUTE_EXTENSIONS), - ); - } + logger.debug("Page entity was not found", { + routeSegmentCount, + }); + return null; + } - if (normalizedSlug === "index" || normalizedSlug === "") { - candidatePaths.unshift( - ...buildFileCandidates( - projectDir, - [pagesDirectory], - "index", - DIRECT_ROUTE_EXTENSIONS, - ), - ); - } + const candidates: EntityCandidate[] = pageStems.flatMap((stem) => + buildFileCandidates( + projectDir, + [pagesDirectory], + stem, + PAGE_FILE_EXTENSIONS, + ).map((path) => ({ path, root: projectDir, virtualRoot: pagesDirectory })) + ); - const candidateResults = await parallelMap(candidatePaths, async (candidatePath) => { - return await getEntityInfo(candidatePath, adapter); - }); + let directCandidateCount = 0; + if (isVeryfrontRoute) { + const directCandidates = buildFileCandidates( + projectDir, + [], + normalizedSlug, + DIRECT_ROUTE_EXTENSIONS, + ).map( + (path) => ({ path, root: projectDir, virtualRoot: ".veryfront" }), + ); + directCandidateCount = directCandidates.length; + candidates.unshift(...directCandidates); + } - for (const info of candidateResults) { - if (info?.entity.isPage) return withResolvedSlug(info, normalizedSlug); - } + const candidateResults: Array = []; + for (const candidate of candidates) { + context.throwIfCancelled(); + candidateResults.push( + await getEntityInfoWithinRoot( + candidate.path, + candidate.root, + adapter, + candidate.virtualRoot, + context, + ), + ); + } - const dynamicPage = await findDynamicPageEntity( - projectDir, - normalizedSlug, - adapter, - pagesDirectory, - ); - return dynamicPage ? withResolvedSlug(dynamicPage, normalizedSlug) : null; - }, - { - "entity.slug": slug, - "entity.normalized_slug": normalizeSlug(slug), - "entity.projectDir": projectDir, - }, + if (directCandidateCount > 0) { + const directPage = selectPage( + candidateResults.slice(0, directCandidateCount).filter(isPageEntityInfo), + routeSegmentCount, + "exact", + ); + if (directPage) return withResolvedSlug(directPage, normalizedSlug); + } + + const exactPage = selectPage( + candidateResults.slice(directCandidateCount).filter(isPageEntityInfo), + routeSegmentCount, + "exact", + ); + if (exactPage) return withResolvedSlug(exactPage, normalizedSlug); + + const dynamicPage = await findDynamicPageEntity( + projectDir, + normalizedSlug, + adapter, + pagesDirectory, + context, + ); + return dynamicPage ? withResolvedSlug(dynamicPage, normalizedSlug) : null; + }, + { + "entity.route_segments": routeSegmentCount, + }, + ), ); } +/** + * Resolves a layout entity by alias, project-relative path, or naming convention. + * + * Returns `null` when the requested layout cannot be found inside the project root. + */ export async function getLayoutEntity( projectDir: string, layoutName: string, adapter?: RuntimeAdapter, + options: EntityResolutionOptions = {}, ): Promise { - return await withSpan( - "types.getLayoutEntity", - async () => { - let resolvedLayoutName = layoutName; - if (layoutName.startsWith("@components/")) { - resolvedLayoutName = layoutName.replace("@components/", "components/"); - } else if (layoutName.startsWith("@/")) { - resolvedLayoutName = layoutName.substring(2); - } + if (!isBoundedPath(projectDir) || !isBoundedPath(layoutName)) return null; + const context = createResolutionContext(adapter, options); + return await withProjectResolutionAdmission( + getResolutionScope(projectDir, options), + adapter, + context, + () => + withSpan( + "types.getLayoutEntity", + async () => { + context.throwIfCancelled(); + let resolvedLayoutName = layoutName; + if (layoutName.startsWith("@components/")) { + resolvedLayoutName = layoutName.replace("@components/", "components/"); + } else if (layoutName.startsWith("@/")) { + resolvedLayoutName = layoutName.substring(2); + } - if (/\.(mdx|md|tsx|jsx|ts|js)$/.test(resolvedLayoutName)) { - const directPath = pathHelper.join(projectDir, resolvedLayoutName); - const info = await getEntityInfo(directPath, adapter); - if (info?.entity.isLayout) return info; - // If explicit path with extension fails, don't fall back to convention-based discovery - return null; - } + if (!isSafeProjectRelativePath(resolvedLayoutName)) return null; - // Files in layouts/ are treated as layouts by convention (any extension) - const layoutCandidatePaths = buildFileCandidates( - projectDir, - ["layouts"], - resolvedLayoutName, - LAYOUT_FILE_EXTENSIONS, - ); + if (/\.(mdx|md|tsx|jsx|ts|js)$/i.test(resolvedLayoutName)) { + const directPath = pathHelper.join(projectDir, resolvedLayoutName); + const info = await getEntityInfoWithinRoot( + directPath, + projectDir, + adapter, + "", + context, + ); + if (info?.entity.isLayout) return info; + if (info && isCanonicalLayoutsPath(resolvedLayoutName)) { + return asLayoutEntity(info); + } + // If explicit path with extension fails, don't fall back to convention-based discovery + return null; + } - // Files in components/ must be detected as layouts by name/frontmatter - const componentCandidatePaths = [ - ...buildFileCandidates( - projectDir, - ["components"], - `${resolvedLayoutName}Layout`, - LAYOUT_FILE_EXTENSIONS, - ), - ...buildFileCandidates(projectDir, ["components"], "Layout", LAYOUT_FILE_EXTENSIONS), - ]; + // Files in layouts/ are treated as layouts by convention (any extension) + const layoutCandidatePaths = buildFileCandidates( + projectDir, + ["layouts"], + resolvedLayoutName, + LAYOUT_FILE_EXTENSIONS, + ); - const candidateResults = await parallelMap( - [...layoutCandidatePaths, ...componentCandidatePaths], - async (candidatePath) => { - return await getEntityInfo(candidatePath, adapter); - }, - ); + // Files in components/ must be detected as layouts by name/frontmatter + const componentLayoutPaths = buildFileCandidates( + projectDir, + ["components"], + `${resolvedLayoutName}Layout`, + LAYOUT_FILE_EXTENSIONS, + ); + const componentFallbackPaths = buildFileCandidates( + projectDir, + ["components"], + "Layout", + LAYOUT_FILE_EXTENSIONS, + ); - const layoutCandidateCount = layoutCandidatePaths.length; - for (let i = 0; i < candidateResults.length; i++) { - const info = candidateResults[i]; - if (!info) continue; - // layouts/ dir: any valid entity is a layout - // components/ dir: must be detected as layout by name/frontmatter - if (i < layoutCandidateCount || info.entity.isLayout) { - return { - entity: { - ...info.entity, - type: "layout", - isLayout: true, - isComponent: false, - isPage: false, - }, - }; - } - } + const candidateResults: Array = []; + for ( + const candidatePath of [ + ...layoutCandidatePaths, + ...componentLayoutPaths, + ...componentFallbackPaths, + ] + ) { + context.throwIfCancelled(); + candidateResults.push( + await getEntityInfoWithinRoot( + candidatePath, + projectDir, + adapter, + "", + context, + ), + ); + } - return null; - }, - { "layout.name": layoutName, "layout.projectDir": projectDir }, + const layoutEnd = layoutCandidatePaths.length; + const componentEnd = layoutEnd + componentLayoutPaths.length; + const conventionalLayout = selectUniqueLayout( + candidateResults.slice(0, layoutEnd).filter(isEntityInfo), + ); + if (conventionalLayout) return conventionalLayout; + + const componentLayout = selectUniqueLayout( + candidateResults.slice(layoutEnd, componentEnd).filter(isLayoutEntityInfo), + ); + if (componentLayout) return componentLayout; + + const fallbackLayout = selectUniqueLayout( + candidateResults.slice(componentEnd).filter(isLayoutEntityInfo), + ); + if (fallbackLayout) return fallbackLayout; + + return null; + }, + { "layout.has_name": layoutName.length > 0 }, + ), ); } @@ -386,51 +1042,363 @@ function buildFileCandidates( ); } +function isCanonicalLayoutsPath(projectRelativePath: string): boolean { + const [rootSegment] = splitPathSegments(projectRelativePath).filter((segment) => + segment !== "" && segment !== "." + ); + return rootSegment === "layouts"; +} + +function buildPageStems(normalizedSlug: string): string[] { + return normalizedSlug === "" || normalizedSlug === "index" + ? ["index"] + : [normalizedSlug, `${normalizedSlug}/index`]; +} + +async function resolveAdapterPageCandidate( + candidate: EntityCandidate, + adapter: RuntimeAdapter | undefined, + context: ResolutionContext, +): Promise { + const resolveFile = context.authority.resolveFile; + if (!resolveFile) return []; + + const pathSegments = splitPathSegments(candidate.path); + const expectedStem = pathSegments.at(-1) ?? ""; + const parentDirectory = pathHelper.dirname(candidate.path); + const discoveredPaths = new Set(); + + const resolvedPath = await awaitResolution( + context, + () => resolveFile.invoke(candidate.path), + ); + logger.debug("Adapter page candidate resolved", { + resolved: resolvedPath !== null, + }); + if (resolvedPath === null) return []; + if (typeof resolvedPath !== "string" || !isBoundedPath(resolvedPath)) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an invalid resolved path", + }); + } + + try { + const entries = await readDirectoryEntries(parentDirectory, context); + for (const entry of entries) { + if ( + entry.isFile && + isSafeDirectoryEntryName(entry.name) && + isPageFileStem(entry.name, expectedStem) + ) { + const discoveredPath = pathHelper.join(parentDirectory, entry.name); + if (isBoundedPath(discoveredPath)) discoveredPaths.add(discoveredPath); + } + } + } catch (error) { + if (!isFileNotFoundError(error)) throw error; + /* expected: the candidate directory may not exist */ + } + + if (discoveredPaths.size === 0) discoveredPaths.add(resolvedPath); + assertMatchingCandidateLimit(discoveredPaths.size); + + const results: Array = []; + for (const path of [...discoveredPaths].sort()) { + context.throwIfCancelled(); + results.push( + await getEntityInfoWithinRoot( + path, + candidate.root, + adapter, + candidate.virtualRoot, + context, + ), + ); + } + return results.filter(isEntityInfo); +} + async function findDynamicPageEntity( projectDir: string, normalizedSlug: string, adapter?: RuntimeAdapter, pagesDirectory = "pages", + context = createResolutionContext(adapter), ): Promise { - const slugParts = normalizedSlug === "" ? [] : normalizedSlug.split("/"); - // Begin one level deeper than the slug so an optional catch-all in the slug's - // own directory can match with zero remaining segments — e.g. `/optional` - // resolving `pages/optional/[[...slug]].tsx`. At that top depth only optional - // catch-alls may match, since any other dynamic segment needs a real value. - for (let depth = slugParts.length; depth >= 0; depth--) { - const optionalCatchAllOnly = depth === slugParts.length; - const parentPath = slugParts.slice(0, depth).join("/"); - const pagesDir = parentPath - ? pathHelper.join(projectDir, pagesDirectory, parentPath) - : pathHelper.join(projectDir, pagesDirectory); + const slugParts = normalizedSlug === "" || normalizedSlug === "index" + ? [] + : normalizedSlug.split("/"); + const pagesRoot = pathHelper.join(projectDir, pagesDirectory); + return await findPageInDirectory( + pagesRoot, + projectDir, + pagesDirectory, + slugParts, + 0, + adapter, + 0, + { directoriesVisited: 0, entriesInspected: 0 }, + context, + ); +} - try { - const entries = await readDirectoryEntries(pagesDir, adapter); - const dynamicEntries = entries.filter( - (entry) => - entry.isFile && - DYNAMIC_PAGE_ENTRY_PATTERN.test(entry.name) && - (!optionalCatchAllOnly || OPTIONAL_CATCH_ALL_ENTRY_PATTERN.test(entry.name)), - ); +async function findPageInDirectory( + directoryPath: string, + projectDir: string, + pagesDirectory: string, + slugParts: readonly string[], + segmentIndex: number, + adapter: RuntimeAdapter | undefined, + dynamicDirectoryDepth: number, + budget: DynamicTraversalBudget, + context: ResolutionContext, +): Promise { + context.throwIfCancelled(); + budget.directoriesVisited += 1; + if (budget.directoriesVisited > MAX_DYNAMIC_DIRECTORIES) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: + `Dynamic route directory traversal exceeds the ${MAX_DYNAMIC_DIRECTORIES}-directory limit`, + }); + } + if (dynamicDirectoryDepth > slugParts.length + 1) return null; + if (!isLexicallyWithinRoot(directoryPath, projectDir, pagesDirectory)) return null; - const candidateResults = await parallelMap(dynamicEntries, async (entry) => { - const candidatePath = pathHelper.join(pagesDir, entry.name); - return await getEntityInfo(candidatePath, adapter); + let entries: DirectoryEntry[]; + try { + const rawEntries = await readDirectoryEntries(directoryPath, context); + budget.entriesInspected += rawEntries.length; + if (budget.entriesInspected > MAX_DYNAMIC_ENTRIES) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: `Dynamic route traversal exceeds the ${MAX_DYNAMIC_ENTRIES}-entry limit`, }); + } + entries = rawEntries.filter((entry) => isSafeDirectoryEntryName(entry.name)); + } catch (error) { + if (isFileNotFoundError(error)) return null; + throw error; + } - for (const info of candidateResults) { - if (info?.entity.isPage) return info; - } - } catch (_) { - /* expected: directory may not exist or readDir may fail */ + const remainingSegmentCount = slugParts.length - segmentIndex; + const exactResults: EntityInfo[] = []; + + if (remainingSegmentCount === 0) { + exactResults.push( + ...await loadPageEntries( + directoryPath, + entries.filter((entry) => entry.isFile && isPageFileStem(entry.name, "index")), + projectDir, + adapter, + pagesDirectory, + context, + ), + ); + } else { + const segment = slugParts[segmentIndex] ?? ""; + if (remainingSegmentCount === 1) { + exactResults.push( + ...await loadPageEntries( + directoryPath, + entries.filter((entry) => entry.isFile && isPageFileStem(entry.name, segment)), + projectDir, + adapter, + pagesDirectory, + context, + ), + ); + } + + const literalDirectory = entries.find((entry) => entry.isDirectory && entry.name === segment); + if (literalDirectory) { + const nested = await findPageInDirectory( + pathHelper.join(directoryPath, literalDirectory.name), + projectDir, + pagesDirectory, + slugParts, + segmentIndex + 1, + adapter, + dynamicDirectoryDepth, + budget, + context, + ); + if (nested) exactResults.push(nested); } } + const exactPage = selectPage(exactResults, slugParts.length, "exact"); + if (exactPage) return exactPage; + + const dynamicFiles = entries + .filter((entry) => entry.isFile) + .map((entry) => ({ + entry, + priority: getDynamicPagePriority(entry.name, remainingSegmentCount), + })) + .filter( + (candidate): candidate is { entry: DirectoryEntry; priority: number } => + candidate.priority !== null, + ); + const dynamicDirectories = entries + .filter((entry) => entry.isDirectory) + .map((entry) => ({ + entry, + match: getDynamicDirectoryMatch(entry.name, remainingSegmentCount), + })) + .filter( + ( + candidate, + ): candidate is { + entry: DirectoryEntry; + match: { consumedSegments: number; priority: number }; + } => candidate.match !== null, + ); + + assertMatchingCandidateLimit(dynamicFiles.length); + assertMatchingCandidateLimit(dynamicDirectories.length); + + for (const priority of [0, 1, 2]) { + const matches: EntityInfo[] = await loadPageEntries( + directoryPath, + dynamicFiles + .filter((candidate) => candidate.priority === priority) + .map((candidate) => candidate.entry), + projectDir, + adapter, + pagesDirectory, + context, + ); + + for (const candidate of dynamicDirectories) { + if (candidate.match.priority !== priority) continue; + const nested = await findPageInDirectory( + pathHelper.join(directoryPath, candidate.entry.name), + projectDir, + pagesDirectory, + slugParts, + segmentIndex + candidate.match.consumedSegments, + adapter, + dynamicDirectoryDepth + 1, + budget, + context, + ); + if (nested) matches.push(nested); + } + + const page = selectPage(matches, slugParts.length, "dynamic"); + if (page) return page; + } + return null; } +function getDynamicPagePriority( + fileName: string, + remainingSegmentCount: number, +): number | null { + const parameter = parseRouteParameterSegment(fileName); + if (!parameter || !SUPPORTED_PAGE_SUFFIX_PATTERN.test(parameter.suffix)) { + return null; + } + switch (parameter.kind) { + case "optional-catch-all": + return 2; + case "catch-all": + return remainingSegmentCount > 0 ? 1 : null; + case "dynamic": + return remainingSegmentCount === 1 ? 0 : null; + } +} + +function getDynamicDirectoryMatch( + directoryName: string, + remainingSegmentCount: number, +): { consumedSegments: number; priority: number } | null { + const parameter = parseRouteParameterSegment(directoryName); + if (!parameter || parameter.suffix !== "") return null; + switch (parameter.kind) { + case "optional-catch-all": + return { consumedSegments: remainingSegmentCount, priority: 2 }; + case "catch-all": + return remainingSegmentCount > 0 + ? { consumedSegments: remainingSegmentCount, priority: 1 } + : null; + case "dynamic": + return remainingSegmentCount > 0 ? { consumedSegments: 1, priority: 0 } : null; + } +} + +function isPageFileStem(fileName: string, expectedStem: string): boolean { + if (!SUPPORTED_PAGE_EXTENSION_PATTERN.test(fileName)) return false; + return fileName.replace(SUPPORTED_PAGE_EXTENSION_PATTERN, "") === expectedStem; +} + +function isSafeDirectoryEntryName(name: string): boolean { + return name !== "" && name !== "." && name !== ".." && + name.length <= MAX_PATH_LENGTH_CHARS && + !containsPathControlCharacters(name) && + !name.includes("/") && !name.includes("\\"); +} + +async function loadPageEntries( + directoryPath: string, + entries: readonly DirectoryEntry[], + projectDir: string, + adapter: RuntimeAdapter | undefined, + pagesDirectory: string, + context: ResolutionContext, +): Promise { + assertMatchingCandidateLimit(entries.length); + const candidates: EntityInfo[] = []; + for (const entry of entries) { + context.throwIfCancelled(); + const info = await getEntityInfoWithinRoot( + pathHelper.join(directoryPath, entry.name), + projectDir, + adapter, + pagesDirectory, + context, + ); + if (info?.entity.isPage) candidates.push(info); + } + return candidates; +} + +function assertMatchingCandidateLimit(candidateCount: number): void { + if (candidateCount <= MAX_MATCHING_ROUTE_CANDIDATES) return; + throw DYNAMIC_ROUTE_ERROR.create({ + detail: `Matching route candidates exceed the ${MAX_MATCHING_ROUTE_CANDIDATES}-candidate limit`, + context: { candidateCount }, + }); +} + +function selectPage( + candidates: readonly EntityInfo[], + routeSegmentCount: number, + matchKind: "dynamic" | "exact", +): EntityInfo | null { + const uniqueCandidates = new Map(); + for (const candidate of candidates) { + uniqueCandidates.set(candidate.entity.path, candidate); + } + if (uniqueCandidates.size > 1) { + throw ROUTE_CONFLICT.create({ + detail: `Multiple ${matchKind} page files match the same route`, + context: { + candidateCount: uniqueCandidates.size, + routeSegmentCount, + }, + }); + } + return uniqueCandidates.values().next().value ?? null; +} + +function isPageEntityInfo(candidate: EntityInfo | null): candidate is EntityInfo { + return candidate?.entity.isPage === true; +} + function withResolvedSlug(info: EntityInfo, normalizedSlug: string): EntityInfo { return { + ...info, entity: { ...info.entity, slug: normalizedSlug === "index" ? "" : normalizedSlug, @@ -440,28 +1408,266 @@ function withResolvedSlug(info: EntityInfo, normalizedSlug: string): EntityInfo async function readDirectoryEntries( pagesDir: string, - adapter?: RuntimeAdapter, + context: ResolutionContext, ): Promise { - const entries: DirectoryEntry[] = []; - const iterator = adapter?.fs.readDir ? adapter.fs.readDir(pagesDir) : fs.readDir(pagesDir); + const collectEntries = async (iterator: AsyncIterable): Promise => { + const entries: DirectoryEntry[] = []; + for await (const entry of iterator) { + context.throwIfCancelled(); + if (entries.length >= MAX_DIRECTORY_ENTRIES) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: `Route directory entries exceed the ${MAX_DIRECTORY_ENTRIES}-entry limit`, + }); + } + entries.push(snapshotDirectoryEntry(entry)); + } + return entries; + }; - for await (const entry of iterator) { - entries.push(entry); + const readDir = context.authority.readDir; + if (!readDir) { + throw INVALID_ROUTE_FILE.create({ + detail: "Route filesystem must provide a readDir data-property method", + }); } - - return entries; + return await awaitResolution( + context, + () => collectEntries(readDir.invoke(pagesDir)), + ); } -function getSlugFromPath(filePath: string): string { - const parts = filePath.split(pathHelper.sep); +function getSlugFromPath(filePath: string, routeRoot?: string): string { + const parts = splitPathSegments(filePath); const fileName = parts[parts.length - 1] ?? ""; - const slug = fileName.replace(/\.(mdx?|tsx?|jsx?)$/, ""); - if (slug !== "index") return slug; + const slug = fileName.replace(/\.(mdx?|tsx?|jsx?)$/i, ""); + if (slug.toLowerCase() !== "index") return slug; + if (routeRoot !== undefined) { + const parent = pathHelper.dirname(filePath); + if (!hasPathPrefix(parent, routeRoot)) return ""; + const relativeParent = pathHelper.relative(routeRoot, parent).replaceAll("\\", "/"); + return relativeParent === "." ? "" : normalizeSlug(relativeParent); + } const parentDir = parts[parts.length - 2]; - return parentDir === "pages" ? "" : parentDir ?? ""; + return parentDir ?? ""; +} + +function splitPathSegments(filePath: string): string[] { + return filePath.split(/[\\/]/); } function normalizeSlug(slug: string): string { - return slug === "/" ? "" : slug.replace(/^\/+/, "").replace(/\/+$/, ""); + return slug.split("/").filter((segment) => segment !== "" && segment !== ".").join("/"); +} + +function countPathSegments(path: string): number { + return path === "" ? 0 : path.split("/").filter(Boolean).length; +} + +function isSafeProjectRelativePath(path: string): boolean { + return isBoundedPath(path) && + !pathHelper.isAbsolute(path) && + path.split(/[\\/]/).every((segment) => segment !== ".."); +} + +function isSafeRouteSlug(slug: string): boolean { + return isSafeProjectRelativePath(slug) && !slug.includes("\\"); +} + +function isBoundedPath(path: unknown): path is string { + return typeof path === "string" && path.length <= MAX_PATH_LENGTH_CHARS && + !containsPathControlCharacters(path); +} + +function isBoundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && + value.length <= MAX_PATH_LENGTH_CHARS && + !containsPathControlCharacters(value); +} + +function snapshotDirectoryEntry(value: unknown): DirectoryEntry { + if (typeof value !== "object" || value === null) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an invalid directory entry", + }); + } + let nameDescriptor: PropertyDescriptor | undefined; + let isFileDescriptor: PropertyDescriptor | undefined; + let isDirectoryDescriptor: PropertyDescriptor | undefined; + try { + nameDescriptor = Reflect.getOwnPropertyDescriptor(value, "name"); + isFileDescriptor = Reflect.getOwnPropertyDescriptor(value, "isFile"); + isDirectoryDescriptor = Reflect.getOwnPropertyDescriptor(value, "isDirectory"); + } catch { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an unreadable directory entry", + }); + } + if ( + !nameDescriptor?.enumerable || !("value" in nameDescriptor) || + !isFileDescriptor?.enumerable || !("value" in isFileDescriptor) || + !isDirectoryDescriptor?.enumerable || !("value" in isDirectoryDescriptor) + ) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an invalid directory entry", + }); + } + const name: unknown = nameDescriptor.value; + const isFile: unknown = isFileDescriptor.value; + const isDirectory: unknown = isDirectoryDescriptor.value; + if ( + typeof name !== "string" || typeof isFile !== "boolean" || + typeof isDirectory !== "boolean" || (isFile && isDirectory) + ) { + throw DYNAMIC_ROUTE_ERROR.create({ + detail: "Route adapter returned an invalid directory entry", + }); + } + return Object.freeze({ name, isFile, isDirectory }); +} + +function isEntityInfo(value: EntityInfo | null): value is EntityInfo { + return value !== null; +} + +function isLayoutEntityInfo(value: EntityInfo | null): value is EntityInfo { + return value?.entity.isLayout === true; +} + +function selectUniqueLayout(candidates: readonly EntityInfo[]): EntityInfo | null { + if (candidates.length > 1) { + throw ROUTE_CONFLICT.create({ + detail: "Multiple layout files match the same layout", + context: { candidateCount: candidates.length }, + }); + } + const info = candidates[0]; + return info ? asLayoutEntity(info) : null; +} + +function asLayoutEntity(info: EntityInfo): EntityInfo { + return { + ...info, + entity: { + ...info.entity, + type: "layout", + isLayout: true, + isComponent: false, + isPage: false, + }, + }; +} + +async function getEntityInfoWithinRoot( + filePath: string, + rootDir: string, + adapter?: RuntimeAdapter, + virtualRoot = "", + context = createResolutionContext(adapter), +): Promise { + if ( + !isBoundedPath(filePath) || + !isBoundedPath(rootDir) || + !isBoundedPath(virtualRoot) + ) return null; + if (!isLexicallyWithinRoot(filePath, rootDir, virtualRoot)) return null; + + let content: string; + try { + if (context.authority.symlinkFree) { + const source = await awaitResolution( + context, + () => + context.authority.bounded.readUtf8( + filePath, + MAX_ENTITY_SOURCE_BYTES, + "Entity source", + ), + ); + content = source.content; + } else { + const snapshot = context.authority.snapshot; + if (!snapshot) { + throw INVALID_ROUTE_FILE.create({ + detail: + "Route filesystem must provide a root-bound stable snapshot reader when links may be resolved", + }); + } + const bytes = await awaitResolution( + context, + () => snapshot.read(filePath, rootDir, MAX_ENTITY_SOURCE_BYTES), + ); + content = strictTextDecoder.decode(bytes); + } + } catch (error) { + if (isFileNotFoundError(error) || isFileSnapshotPathError(error)) return null; + throw error; + } + + return createEntityInfo( + filePath, + content, + context.authority, + getExplicitRouteRoot(filePath, rootDir, virtualRoot), + ); +} + +function getExplicitRouteRoot( + filePath: string, + rootDir: string, + virtualRoot: string, +): string | undefined { + if (virtualRoot === "" || virtualRoot === ".") return undefined; + const rootedRouteDirectory = pathHelper.join(rootDir, virtualRoot); + if (hasPathPrefix(filePath, rootedRouteDirectory)) return rootedRouteDirectory; + return hasPathPrefix(filePath, virtualRoot) ? virtualRoot : undefined; +} + +function isLexicallyWithinRoot( + filePath: string, + rootDir: string, + virtualRoot = "", +): boolean { + if (filePath.split(/[\\/]/).some((segment) => segment === "..")) return false; + + const fileIsAbsolute = pathHelper.isAbsolute(filePath); + const rootIsAbsolute = pathHelper.isAbsolute(rootDir); + if (fileIsAbsolute) return rootIsAbsolute && hasPathPrefix(filePath, rootDir); + if (!rootIsAbsolute && hasPathPrefix(filePath, rootDir)) return true; + return virtualRoot === "" || virtualRoot === "." + ? !rootIsAbsolute + : hasPathPrefix(filePath, virtualRoot); +} + +function hasNoSymlinkSemantics(fileSystem: object): boolean { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(fileSystem, "symlinkSemantics"); + return descriptor !== undefined && + "value" in descriptor && + descriptor.value === "none"; + } catch { + return false; + } +} + +function hasPathPrefix(filePath: string, rootDir: string): boolean { + const normalizedPath = normalizeComparablePath(filePath); + const normalizedRoot = normalizeComparablePath(rootDir); + if (normalizedRoot === ".") { + return !pathHelper.isAbsolute(normalizedPath) && + normalizedPath !== ".." && + !normalizedPath.startsWith("../"); + } + const descendantPrefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`; + return normalizedPath === normalizedRoot || normalizedPath.startsWith(descendantPrefix); +} + +function normalizeComparablePath(path: string): string { + const normalized = pathHelper.normalize(path.replace(/\\/g, "/")); + const withoutTrailingSlash = normalized === "/" || /^[A-Za-z]:\/$/.test(normalized) + ? normalized + : normalized.replace(/\/$/, ""); + return /^[A-Za-z]:\//.test(withoutTrailingSlash) + ? withoutTrailingSlash.toLowerCase() + : withoutTrailingSlash; } diff --git a/src/utils/constants/limits.ts b/src/utils/constants/limits.ts index 56c14091cd..881c28a731 100644 --- a/src/utils/constants/limits.ts +++ b/src/utils/constants/limits.ts @@ -25,6 +25,8 @@ export const HANDLER_CACHE_MAX_ENTRIES = 256; export const MAX_CACHE_KEY_CHARACTERS = 16_384; export const MAX_PATH_LENGTH_CHARS = 4096; +/** Maximum number of path segments admitted by framework route resolvers. */ +export const MAX_ROUTE_SEGMENTS = 64; export const MAX_PORT_NUMBER = 65535; export const MIN_PORT_NUMBER = 1; export const MAX_URL_LENGTH_FOR_VALIDATION = 2048; diff --git a/src/utils/route-path-utils.test.ts b/src/utils/route-path-utils.test.ts index 351ddcdf66..f888f4f9aa 100644 --- a/src/utils/route-path-utils.test.ts +++ b/src/utils/route-path-utils.test.ts @@ -11,13 +11,14 @@ import { isDynamicRoute, isDynamicSegment, matchesPattern, + parseRouteParameterSegment, removeFileExtension, } from "./route-path-utils.ts"; describe("route-path-utils", () => { describe("isDynamicSegment", () => { it("should detect standard dynamic segments", () => { - const segments = ["[id]", "[slug]", "[userId]"] as const; + const segments = ["[id]", "[slug]", "[userId]", "[version.number]", "[post-id]"] as const; for (const segment of segments) { assertEquals(isDynamicSegment(segment), true); @@ -33,7 +34,7 @@ describe("route-path-utils", () => { }); it("should detect optional catch-all segments", () => { - const segments = ["[[...slug]]", "[[...params]]"] as const; + const segments = ["[[...slug]]", "[[...params]]", "[[...slug]].tsx"] as const; for (const segment of segments) { assertEquals(isDynamicSegment(segment), true); @@ -65,9 +66,75 @@ describe("route-path-utils", () => { }); }); + describe("parseRouteParameterSegment", () => { + it("parses supported dynamic segment forms and file suffixes", () => { + assertEquals(parseRouteParameterSegment("[id]"), { + name: "id", + kind: "dynamic", + suffix: "", + }); + assertEquals(parseRouteParameterSegment("[version.number]"), { + name: "version.number", + kind: "dynamic", + suffix: "", + }); + assertEquals(parseRouteParameterSegment("[...path].MDX"), { + name: "path", + kind: "catch-all", + suffix: ".MDX", + }); + assertEquals(parseRouteParameterSegment("[[...slug]].tsx"), { + name: "slug", + kind: "optional-catch-all", + suffix: ".tsx", + }); + }); + + it("rejects incomplete or unsafe parameter syntax", () => { + const invalid = [ + "[]", + "[...].tsx", + "[[...slug].tsx", + "[a/b].tsx", + "[a\\b].tsx", + "[my param].tsx", + "[slug!].tsx", + "[.slug].tsx", + "[slug.].tsx", + "[slug..part].tsx", + "[id]tsx", + "[slug].draft", + "[slug].draft.mdx", + "[id]\n.tsx", + ] as const; + + for (const segment of invalid) { + assertEquals(parseRouteParameterSegment(segment), null); + } + }); + + it("parses hyphenated parameter names", () => { + assertEquals(parseRouteParameterSegment("[post-id]"), { + name: "post-id", + kind: "dynamic", + suffix: "", + }); + assertEquals(parseRouteParameterSegment("[post-id].tsx"), { + name: "post-id", + kind: "dynamic", + suffix: ".tsx", + }); + }); + }); + describe("isDynamicRoute", () => { it("should detect routes with dynamic segments", () => { - const routes = ["/users/[id]", "[...slug]", "/blog/[year]/[month]"] as const; + const routes = [ + "/users/[id]", + "[...slug]", + "/blog/[year]/[month]", + "/api/[version.number]", + ] as const; for (const route of routes) { assertEquals(isDynamicRoute(route), true); @@ -137,6 +204,7 @@ describe("route-path-utils", () => { it("should extract name from standard segments", () => { assertEquals(extractParamName("[id]"), "id"); assertEquals(extractParamName("[slug]"), "slug"); + assertEquals(extractParamName("[version.number]"), "version.number"); }); it("should extract name from catch-all segments", () => { @@ -147,6 +215,7 @@ describe("route-path-utils", () => { it("should extract name from optional catch-all segments", () => { assertEquals(extractParamName("[[...slug]]"), "slug"); assertEquals(extractParamName("[[...params]]"), "params"); + assertEquals(extractParamName("[[...params]].tsx"), "params"); }); }); @@ -212,6 +281,31 @@ describe("route-path-utils", () => { assertEquals(result.params["slug"], ["getting-started", "intro"]); }); + it("extracts optional catch-all params with zero remaining segments", () => { + const result = extractRouteParams( + "/app/docs/[[...slug]]/page.tsx", + "docs", + ); + + assertEquals(result.matched, true); + assertEquals(result.params["slug"], []); + }); + + it("preserves __proto__ route params without changing the params prototype", () => { + const dynamic = extractRouteParams("/app/users/[__proto__]/page.tsx", "users/123"); + assertEquals(dynamic.matched, true); + assertEquals(dynamic.params["__proto__"], "123"); + assertEquals(Object.getPrototypeOf(dynamic.params), null); + + const catchAll = extractRouteParams( + "/app/docs/[...__proto__]/page.tsx", + "docs/a/b", + ); + assertEquals(catchAll.matched, true); + assertEquals(catchAll.params["__proto__"], ["a", "b"]); + assertEquals(Object.getPrototypeOf(catchAll.params), null); + }); + it("extracts params from configured router roots", () => { const result = extractRouteParams( "/project/src/legacy-pages/users/[id].tsx", @@ -253,6 +347,16 @@ describe("route-path-utils", () => { assertEquals(extractParamsFromPattern("[id]", "123"), { id: "123" }); }); + it("preserves __proto__ pattern params without changing the params prototype", () => { + const dynamic = extractParamsFromPattern("[__proto__]", "123"); + assertEquals(dynamic?.["__proto__"], "123"); + assertEquals(Object.getPrototypeOf(dynamic), null); + + const catchAll = extractParamsFromPattern("[...__proto__]", "a/b"); + assertEquals(catchAll?.["__proto__"], ["a", "b"]); + assertEquals(Object.getPrototypeOf(catchAll), null); + }); + it("should extract multiple params", () => { assertEquals(extractParamsFromPattern("[year]/[month]", "2024/01"), { year: "2024", diff --git a/src/utils/route-path-utils.ts b/src/utils/route-path-utils.ts index d4be19e02a..193ea8117d 100644 --- a/src/utils/route-path-utils.ts +++ b/src/utils/route-path-utils.ts @@ -13,6 +13,7 @@ export const COMPONENT_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"] as const; /** Regex for matching and removing file extensions */ const EXTENSION_REGEX = /\.(tsx|jsx|ts|js|mdx|md)$/; +const ROUTE_PARAMETER_FILE_SUFFIX_REGEX = /^\.(tsx|jsx|ts|js|mdx|md)$/i; /** Reject control characters before paths reach runtime filesystem adapters. */ export function containsPathControlCharacters(value: string): boolean { @@ -23,44 +24,80 @@ export function containsPathControlCharacters(value: string): boolean { return false; } -/** Patterns for dynamic segment detection */ -const DYNAMIC_SEGMENT_PATTERNS = { - standard: /^\[[\w]+\]$/, // [id] - catchAll: /^\[\.\.\.[\w]+\]$/, // [...slug] - optionalCatchAll: /^\[\[\.\.\.[\w]+\]\]$/, // [[...slug]] - withExtension: /^\[\.{0,3}\w+\]\.\w+$/, // [id].tsx or [...slug].ts -} as const; +export type RouteParameterKind = + | "dynamic" + | "catch-all" + | "optional-catch-all"; + +export interface ParsedRouteParameter { + name: string; + kind: RouteParameterKind; + /** Literal suffix after the route parameter, such as `.tsx`. */ + suffix: string; +} + +function isValidParameterName(name: string): boolean { + return /^[\w-]+(?:\.[\w-]+)*$/.test(name); +} + +/** Parse a complete dynamic route segment using the public route grammar. */ +export function parseRouteParameterSegment( + segment: string, +): ParsedRouteParameter | null { + if (!segment.startsWith("[") || containsPathControlCharacters(segment)) { + return null; + } + + let marker: string; + let kind: RouteParameterKind; + let closing: string; + if (segment.startsWith("[[...")) { + marker = "[[..."; + kind = "optional-catch-all"; + closing = "]]"; + } else if (segment.startsWith("[...")) { + marker = "[..."; + kind = "catch-all"; + closing = "]"; + } else { + marker = "["; + kind = "dynamic"; + closing = "]"; + } + + const closingIndex = segment.indexOf(closing, marker.length); + if (closingIndex === -1) return null; + + const name = segment.slice(marker.length, closingIndex); + const suffix = segment.slice(closingIndex + closing.length); + if (!isValidParameterName(name)) return null; + if (suffix !== "" && !ROUTE_PARAMETER_FILE_SUFFIX_REGEX.test(suffix)) { + return null; + } + return { name, kind, suffix }; +} /** * Check if a segment name is a dynamic route segment. * Handles both directory names like "[id]" and file names like "[id].tsx" */ export function isDynamicSegment(name: string): boolean { - if (!name.startsWith("[")) return false; - - if (name.endsWith("]")) { - return ( - DYNAMIC_SEGMENT_PATTERNS.standard.test(name) || - DYNAMIC_SEGMENT_PATTERNS.catchAll.test(name) || - DYNAMIC_SEGMENT_PATTERNS.optionalCatchAll.test(name) - ); - } - - return DYNAMIC_SEGMENT_PATTERNS.withExtension.test(name); + return parseRouteParameterSegment(name) !== null; } /** * Check if a route pattern contains any dynamic segments */ export function isDynamicRoute(pattern: string): boolean { - return /\[[\w.]+\]/.test(pattern); + return pattern.split(/[\\/]/).some(isDynamicSegment); } /** * Check if a segment is a catch-all segment ([...slug] or [[...slug]]) */ export function isCatchAllSegment(name: string): boolean { - return name.startsWith("[...") || name.startsWith("[[..."); + const parameter = parseRouteParameterSegment(name); + return parameter?.kind === "catch-all" || parameter?.kind === "optional-catch-all"; } /** @@ -77,7 +114,7 @@ export function removeFileExtension(path: string): string { * "[[...params]]" -> "params" */ export function extractParamName(segment: string): string { - return segment.replace(/\[\[\.\.\.|\[\.\.\.|\[|\]\]|\]/g, ""); + return parseRouteParameterSegment(segment)?.name ?? segment; } /** @@ -145,7 +182,7 @@ export function extractRouteParams( slug: string, directories: RouterDirectories = {}, ): ExtractedRouteParams { - const params: Record = {}; + const params: Record = Object.create(null); const { relativePath } = extractRouterBasePath(pageEntityId, directories); if (!relativePath) return { params, matched: false }; @@ -171,6 +208,17 @@ export function extractRouteParams( params[paramName] = slugSegments[i]!; } + const nextPathSegment = pathSegments[slugSegments.length]; + const nextParameter = nextPathSegment ? parseRouteParameterSegment(nextPathSegment) : null; + if (nextParameter?.kind === "optional-catch-all") { + const staticPrefixMatches = pathSegments + .slice(0, slugSegments.length) + .every((segment, index) => isDynamicSegment(segment) || segment === slugSegments[index]); + if (staticPrefixMatches) { + params[nextParameter.name] = []; + } + } + return { params, matched: Object.keys(params).length > 0 }; } @@ -204,7 +252,7 @@ export function extractParamsFromPattern( const patternParts = pattern.split("/").filter(Boolean); const slugParts = slug.split("/").filter(Boolean); - const params: Record = {}; + const params: Record = Object.create(null); const hasCatchAll = patternParts.some(isCatchAllSegment); if (!hasCatchAll && patternParts.length !== slugParts.length) return null; diff --git a/tests/integration/core/getEntityInfo.containment.test.ts b/tests/integration/core/getEntityInfo.containment.test.ts new file mode 100644 index 0000000000..3ab0b19b43 --- /dev/null +++ b/tests/integration/core/getEntityInfo.containment.test.ts @@ -0,0 +1,122 @@ +import { assertEquals } from "#veryfront/testing/assert"; +import { join } from "#veryfront/compat/path"; +import { describe, it } from "#veryfront/testing/bdd"; +import { mkdir, withTempDir, writeTextFile } from "#veryfront/testing/deno-compat"; +import { getEntityBySlug, getLayoutEntity } from "../../../src/types/entities/getEntityInfo.ts"; + +describe("getEntityBySlug containment", () => { + it("does not resolve slugs outside the pages directory", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + await mkdir(join(projectDir, "pages"), { recursive: true }); + await writeTextFile(join(root, "outside.mdx"), "# Outside"); + + assertEquals(await getEntityBySlug(projectDir, "../../outside"), null); + assertEquals(await getEntityBySlug(projectDir, "./../../outside"), null); + assertEquals(await getEntityBySlug(projectDir, "..\\..\\outside"), null); + }); + }); + + it("rejects pages directories that escape the project", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + await mkdir(join(projectDir, "pages"), { recursive: true }); + await mkdir(join(root, "outside"), { recursive: true }); + await writeTextFile(join(root, "outside", "secret.mdx"), "# Outside"); + + assertEquals( + await getEntityBySlug(projectDir, "secret", undefined, "../outside"), + null, + ); + assertEquals( + await getEntityBySlug( + projectDir, + "secret", + undefined, + join(root, "outside"), + ), + null, + ); + }); + }); + + it("rejects page files and directories that escape through symlinks", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + const pagesDir = join(projectDir, "pages"); + const outsideDir = join(root, "outside"); + await mkdir(pagesDir, { recursive: true }); + await mkdir(outsideDir, { recursive: true }); + await writeTextFile(join(outsideDir, "secret.mdx"), "# Secret"); + await writeTextFile(join(outsideDir, "index.mdx"), "# Secret index"); + await Deno.symlink(join(outsideDir, "secret.mdx"), join(pagesDir, "leak.mdx"), { + type: "file", + }); + await Deno.symlink(outsideDir, join(pagesDir, "linked"), { type: "dir" }); + + assertEquals(await getEntityBySlug(projectDir, "leak"), null); + assertEquals(await getEntityBySlug(projectDir, "linked"), null); + }); + }); + + it("still resolves ordinary slugs inside the pages directory", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + await mkdir(join(projectDir, "pages"), { recursive: true }); + await writeTextFile(join(projectDir, "pages", "about.mdx"), "# About"); + + const result = await getEntityBySlug(projectDir, "about"); + + assertEquals(result?.entity.content, "# About"); + }); + }); +}); + +describe("getLayoutEntity containment", () => { + it("does not resolve layouts outside the project", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + await mkdir(join(projectDir, "layouts"), { recursive: true }); + await writeTextFile( + join(root, "outside.mdx"), + "---\nisLayout: true\n---\n# Outside layout", + ); + + assertEquals(await getLayoutEntity(projectDir, "../outside.mdx"), null); + assertEquals(await getLayoutEntity(projectDir, "@/../outside.mdx"), null); + assertEquals( + await getLayoutEntity(projectDir, join(root, "outside.mdx")), + null, + ); + }); + }); + + it("still resolves ordinary layouts inside the project", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + await mkdir(join(projectDir, "layouts"), { recursive: true }); + await writeTextFile( + join(projectDir, "layouts", "main.mdx"), + "# Main layout", + ); + + const result = await getLayoutEntity(projectDir, "main"); + + assertEquals(result?.entity.content, "# Main layout"); + }); + }); + + it("rejects layout files that escape through symlinks", async () => { + await withTempDir(async (root) => { + const projectDir = join(root, "project"); + const layoutsDir = join(projectDir, "layouts"); + const outsideLayout = join(root, "outside.mdx"); + await mkdir(layoutsDir, { recursive: true }); + await writeTextFile(outsideLayout, "# Outside layout"); + await Deno.symlink(outsideLayout, join(layoutsDir, "main.mdx"), { type: "file" }); + + assertEquals(await getLayoutEntity(projectDir, "main"), null); + assertEquals(await getLayoutEntity(projectDir, "layouts/main.mdx"), null); + }); + }); +}); diff --git a/tests/integration/core/getEntityInfo.hardening.test.ts b/tests/integration/core/getEntityInfo.hardening.test.ts new file mode 100644 index 0000000000..a6380fe32e --- /dev/null +++ b/tests/integration/core/getEntityInfo.hardening.test.ts @@ -0,0 +1,1492 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { join } from "#veryfront/compat/path"; +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert"; +import { describe, it } from "#veryfront/testing/bdd"; +import { mkdir, withTempDir, writeTextFile } from "#veryfront/testing/deno-compat"; +import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; +import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; +import { CloudflareFileSystemAdapter } from "#veryfront/platform/adapters/runtime/cloudflare/filesystem.ts"; +import type { KVNamespace } from "#veryfront/platform/adapters/runtime/cloudflare/types.ts"; +import { + getEntityBySlug, + getEntityInfo, + getLayoutEntity, +} from "../../../src/types/entities/getEntityInfo.ts"; + +async function assertRouteConflict(operation: () => Promise): Promise { + const error = await assertRejects(operation, VeryfrontError); + if (!(error instanceof VeryfrontError)) { + throw new Error("Expected a VeryfrontError route conflict"); + } + assertEquals(error.slug, "route-conflict"); +} + +function createCloudflareKV(initialEntries: Record): KVNamespace { + const entries = new Map(Object.entries(initialEntries)); + return { + delete(key) { + entries.delete(key); + return Promise.resolve(); + }, + get(key, type?: "text" | "json" | "arrayBuffer" | "stream") { + const value = entries.get(key); + if (value === undefined) return Promise.resolve(null); + const bytes = new TextEncoder().encode(value); + if (type === "arrayBuffer") return Promise.resolve(bytes.buffer); + if (type === "stream") { + return Promise.resolve( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + ); + } + return Promise.resolve(value); + }, + getWithMetadata(key) { + return Promise.resolve({ metadata: null, value: entries.get(key) ?? null }); + }, + list(options = {}) { + const prefix = options.prefix ?? ""; + return Promise.resolve({ + keys: [...entries.keys()] + .filter((key) => key.startsWith(prefix)) + .map((name) => ({ name })), + list_complete: true, + cursor: "", + }); + }, + put(key, value) { + if (typeof value !== "string") { + throw new TypeError("Test KV accepts text values only"); + } + entries.set(key, value); + return Promise.resolve(); + }, + }; +} + +function hostedTextRead(content = "# Page") { + return { + readFile: () => Promise.resolve(content), + readFileBytesWithinLimit: (_path: string, byteLimit: number) => { + const bytes = new TextEncoder().encode(content); + if (bytes.byteLength > byteLimit) { + return Promise.reject(new RangeError(`File exceeds ${byteLimit} bytes`)); + } + return Promise.resolve(bytes); + }, + }; +} + +describe("getEntityInfo", () => { + it("removes invalid values from typed frontmatter fields", async () => { + await withTempDir(async (projectDir) => { + const pagePath = join(projectDir, "page.mdx"); + await writeTextFile( + pagePath, + [ + "---", + "title: 42", + "tags:", + " - valid", + " - 7", + "published: yes", + "isLayout: yes", + "custom:", + " nested: true", + "---", + "# Page", + ].join("\n"), + ); + + const result = await getEntityInfo(pagePath); + + assertExists(result); + assertEquals(result.entity.frontmatter.title, undefined); + assertEquals(result.entity.frontmatter.tags, undefined); + assertEquals(result.entity.frontmatter.published, undefined); + assertEquals(result.entity.frontmatter.isLayout, undefined); + assertEquals(result.entity.frontmatter.custom as unknown, { nested: true }); + assertEquals(result.entity.type, "page"); + }); + }); + + it("propagates hosted adapter entity identifier failures", async () => { + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => ({ + getEntityIdForPath: () => { + throw new Error("entity identifier lookup failed"); + }, + }), + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityInfo("/project/pages/page.mdx", adapter), + Error, + "entity identifier lookup failed", + ); + }); + + it("does not reinterpret entity identifier failures as missing files", async () => { + const missingEntityId = Object.assign( + new Error("entity identifier unavailable"), + { code: "ENOENT" }, + ); + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => ({ + getEntityIdForPath: () => { + throw missingEntityId; + }, + }), + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityInfo("/project/pages/page.mdx", adapter), + Error, + "entity identifier unavailable", + ); + }); + + it("preserves the hosted adapter receiver during entity identifier lookup", async () => { + const underlyingAdapter = { + prefix: "entity", + getEntityIdForPath(path: string) { + return `${this.prefix}:${path}`; + }, + }; + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + const result = await getEntityInfo("pages/page.mdx", adapter); + + assertExists(result); + assertEquals(result.entity.id, "entity:pages/page.mdx"); + }); + + it("does not invoke an accessor masquerading as the optional entity identifier hook", async () => { + let accessorReads = 0; + const underlyingAdapter = Object.defineProperty({}, "getEntityIdForPath", { + configurable: true, + get() { + accessorReads++; + throw new Error("entity identifier accessor must not run"); + }, + }); + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + const result = await getEntityInfo("pages/page.mdx", adapter); + + assertExists(result); + assertEquals(result.entity.id, "pages/page.mdx"); + assertEquals(accessorReads, 0); + }); + + it("propagates failures while inspecting the authoritative entity identifier hook", async () => { + const underlyingAdapter = new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error("entity identifier hook inspection failed"); + }, + }); + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityInfo("pages/page.mdx", adapter), + Error, + "entity identifier hook inspection failed", + ); + }); + + it("rejects hosted entity identifiers containing control characters", async () => { + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => ({ + getEntityIdForPath: () => "entity\nidentifier", + }), + isMultiProjectMode: () => false, + ...hostedTextRead(), + }, + } as unknown as RuntimeAdapter; + + const error = await assertRejects( + () => getEntityInfo("/project/pages/page.mdx", adapter), + VeryfrontError, + ); + if (!(error instanceof VeryfrontError)) { + throw new Error("Expected a VeryfrontError for an invalid entity identifier"); + } + assertEquals(error.slug, "invalid-route-file"); + }); + + it("preserves an unreadable adapter rejection without reclassifying it", async () => { + const rejection = new Proxy({}, { + get() { + throw new Error("adapter rejection must not be inspected"); + }, + }); + const adapter = createMockAdapter(); + adapter.fs.readFileBytesWithinLimit = () => Promise.reject(rejection); + + let caught: unknown; + try { + await getEntityInfo("/project/pages/page.mdx", adapter); + } catch (error) { + caught = error; + } + + assertEquals(caught === rejection, true); + }); + + it("propagates a non-native ENOENT-shaped direct-read outage by identity", async () => { + const outage = Object.freeze({ code: "ENOENT", detail: "hosted storage offline" }); + const adapter = createMockAdapter(); + adapter.fs.readFileBytesWithinLimit = () => Promise.reject(outage); + + let caught: unknown; + try { + await getEntityInfo("/project/pages/page.mdx", adapter); + } catch (error) { + caught = error; + } + + assertEquals(caught === outage, true); + }); + + it("does not reinterpret an adapter EISDIR rejection as absence", async () => { + const rejection = Object.assign(new Error("adapter read failed"), { + code: "EISDIR", + }); + const adapter = createMockAdapter(); + adapter.fs.readFileBytesWithinLimit = () => Promise.reject(rejection); + + let caught: unknown; + try { + await getEntityInfo("/project/pages/page.mdx", adapter); + } catch (error) { + caught = error; + } + + assertEquals(caught === rejection, true); + }); + + it("rejects entity sources beyond the bounded page-source limit", async () => { + let unboundedReads = 0; + const adapter = { + fs: { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => ({}), + isMultiProjectMode: () => false, + readFile: () => { + unboundedReads++; + return Promise.resolve("x".repeat(5 * 1024 * 1024 + 1)); + }, + readFileBytesWithinLimit: () => + Promise.reject(new RangeError("source exceeds bounded read")), + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityInfo("/project/pages/page.mdx", adapter), + Error, + "source exceeds", + ); + assertEquals(unboundedReads, 0); + }); + + it("normalizes slugs from case-insensitive supported extensions", async () => { + await withTempDir(async (projectDir) => { + const pagePath = join(projectDir, "article.MDX"); + await writeTextFile(pagePath, "# Article"); + + const result = await getEntityInfo(pagePath); + + assertExists(result); + assertEquals(result.entity.slug, "article"); + assertEquals(result.entity.kind, "mdx"); + }); + }); + + it("derives a complete route slug for nested case-insensitive index files", async () => { + await withTempDir(async (projectDir) => { + const pagePath = join(projectDir, "pages", "blog", "pages", "INDEX.MDX"); + await mkdir(join(projectDir, "pages", "blog", "pages"), { recursive: true }); + await writeTextFile(pagePath, "# Guides"); + + const result = await getEntityInfo(pagePath, undefined, { + routeRoot: join(projectDir, "pages"), + }); + + assertExists(result); + assertEquals(result.entity.slug, "blog/pages"); + }); + }); +}); + +describe("getEntityBySlug", () => { + it("supports Cloudflare KV containment without allowing resolved path escapes", async () => { + const fs = new CloudflareFileSystemAdapter(createCloudflareKV({ + "/outside/secret.mdx": "# Secret", + "/project/pages/about.mdx": "# About", + })); + const adapter: RuntimeAdapter = { + ...createMockAdapter(), + id: "cloudflare", + fs, + }; + + const page = await getEntityBySlug("/project", "about", adapter); + assertEquals(page?.entity.content, "# About"); + + Object.assign(fs, { + resolveFile: () => Promise.resolve("/outside/secret.mdx"), + }); + assertEquals(await getEntityBySlug("/project", "secret", adapter), null); + }); + + it("resolves relative Cloudflare KV projects from the canonical virtual root", async () => { + const fs = new CloudflareFileSystemAdapter(createCloudflareKV({ + "pages/about.mdx": "# Relative about", + })); + const adapter: RuntimeAdapter = { + ...createMockAdapter(), + id: "cloudflare", + fs, + }; + + const page = await getEntityBySlug(".", "about", adapter); + + assertEquals(page?.entity.content, "# Relative about"); + assertEquals(page?.entity.slug, "about"); + }); + + it("normalizes adapter-resolved dot segments and backslashes before KV reads", async () => { + const fs = new CloudflareFileSystemAdapter(createCloudflareKV({ + "/project/pages/about.mdx": "# Canonical about", + })); + Object.assign(fs, { + resolveFile: (path: string) => + Promise.resolve( + path.endsWith("/pages/about") ? "/project\\pages\\.\\about.mdx" : null, + ), + }); + const adapter: RuntimeAdapter = { + ...createMockAdapter(), + id: "cloudflare", + fs, + }; + + const page = await getEntityBySlug("/project", "about", adapter); + + assertEquals(page?.entity.content, "# Canonical about"); + assertEquals(page?.entity.slug, "about"); + }); + + it("uses lexical containment for adapters that explicitly forbid symlinks", async () => { + let resolvedPath = "/project/pages/about.mdx"; + const source = "# Marker-backed page"; + const adapter = { + id: "edge-store", + fs: { + symlinkSemantics: "none", + resolveFile: () => Promise.resolve(resolvedPath), + stat: () => + Promise.resolve({ + size: source.length, + isFile: true, + isDirectory: false, + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve(source), + readFileBytesWithinLimit: (_path: string, byteLimit: number) => { + const bytes = new TextEncoder().encode(source); + return bytes.byteLength <= byteLimit + ? Promise.resolve(bytes) + : Promise.reject(new RangeError("File exceeds byte limit")); + }, + readDir: async function* () {}, + }, + } as unknown as RuntimeAdapter; + + const page = await getEntityBySlug("/project", "about", adapter); + assertEquals(page?.entity.content, source); + + resolvedPath = "/outside/secret.mdx"; + assertEquals(await getEntityBySlug("/project", "secret", adapter), null); + + resolvedPath = "/project/pages/about.mdx"; + const inheritedMarkerAdapter = { + ...adapter, + fs: Object.create(adapter.fs), + } as RuntimeAdapter; + await assertRejects( + () => getEntityBySlug("/project", "about", inheritedMarkerAdapter), + Error, + "root-bound stable snapshot reader", + ); + + let markerAccessorReads = 0; + const accessorMarkerFs = Object.create(adapter.fs); + Object.defineProperty(accessorMarkerFs, "symlinkSemantics", { + get() { + markerAccessorReads++; + return "none"; + }, + }); + await assertRejects( + () => + getEntityBySlug("/project", "about", { + ...adapter, + fs: accessorMarkerFs, + } as RuntimeAdapter), + Error, + "root-bound stable snapshot reader", + ); + assertEquals(markerAccessorReads, 0); + }); + + it("fails closed when a link-resolving adapter lacks a snapshot reader", async () => { + let reads = 0; + const source = new TextEncoder().encode("# About"); + const adapter = { + fs: { + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# About"), + readFileBytesWithinLimit: () => { + reads++; + return Promise.resolve(source); + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "about", adapter), + Error, + "root-bound stable snapshot reader", + ); + assertEquals(reads, 0); + }); + + it("never treats Object.prototype filesystem hooks as adapter authority", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "realPath"); + let pollutedCalls = 0; + let reads = 0; + Object.defineProperty(Object.prototype, "realPath", { + configurable: true, + value: () => { + pollutedCalls++; + return Promise.resolve("/project"); + }, + }); + + try { + const adapter = { + fs: { + resolveFile: (path: string) => Promise.resolve(`${path}.mdx`), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Secret"), + readFileBytesWithinLimit: () => { + reads++; + return Promise.resolve(new TextEncoder().encode("# Secret")); + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "about", adapter), + Error, + "root-bound stable snapshot reader", + ); + assertEquals(pollutedCalls, 0); + assertEquals(reads, 0); + } finally { + if (originalDescriptor) { + Object.defineProperty(Object.prototype, "realPath", originalDescriptor); + } else { + Reflect.deleteProperty(Object.prototype, "realPath"); + } + } + }); + + it("binds containment and bytes to the captured snapshot capability", async () => { + let canonicalizeCalls = 0; + let separateReads = 0; + let snapshotReads = 0; + const adapter = { + fs: { + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Replaced outside source"), + readFileBytesWithinLimit: () => { + separateReads++; + return Promise.resolve(new TextEncoder().encode("# Replaced outside source")); + }, + realPath: () => { + canonicalizeCalls++; + return Promise.resolve("/project/pages/about.mdx"); + }, + readFileSnapshotWithinLimit: ( + path: string, + root: string, + byteLimit: number, + ) => { + snapshotReads++; + assertEquals(path, "/project/pages/about.mdx"); + assertEquals(root, "/project"); + const bytes = new TextEncoder().encode("# Stable inside source"); + if (bytes.byteLength > byteLimit) throw new RangeError("File exceeds byte limit"); + return Promise.resolve(bytes); + }, + }, + } as unknown as RuntimeAdapter; + + const page = await getEntityBySlug("/project", "about", adapter); + + assertEquals(page?.entity.content, "# Stable inside source"); + assertEquals(snapshotReads, 1); + assertEquals(canonicalizeCalls, 0); + assertEquals(separateReads, 0); + }); + + it("propagates a non-native ENOENT-shaped snapshot outage by identity", async () => { + const outage = Object.freeze({ code: "ENOENT", detail: "snapshot service offline" }); + const adapter = { + fs: { + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: () => Promise.resolve(new TextEncoder().encode("# Page")), + readFileSnapshotWithinLimit: () => Promise.reject(outage), + }, + } as unknown as RuntimeAdapter; + + let caught: unknown; + try { + await getEntityBySlug("/project", "about", adapter); + } catch (error) { + caught = error; + } + + assertEquals(caught === outage, true); + }); + + it("propagates a non-native ENOENT-shaped directory outage by identity", async () => { + const outage = Object.freeze({ code: "ENOENT", detail: "directory service offline" }); + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: () => Promise.resolve(null), + readDir: async function* () { + throw outage; + }, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: () => Promise.resolve(new TextEncoder().encode("# Page")), + }, + } as unknown as RuntimeAdapter; + + let caught: unknown; + try { + await getEntityBySlug("/project", "about", adapter); + } catch (error) { + caught = error; + } + + assertEquals(caught === outage, true); + }); + + it("does not grant containment authority from an adapter id", async () => { + let reads = 0; + const adapter = { + id: "memory", + fs: { + resolveFile: (path: string) => Promise.resolve(`${path}.mdx`), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: () => { + reads++; + return Promise.resolve(new TextEncoder().encode("# Page")); + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "about", adapter), + Error, + "root-bound stable snapshot reader", + ); + assertEquals(reads, 0); + }); + + it("preserves Windows drive roots while comparing contained route paths", async () => { + let reads = 0; + const source = "# Windows root page"; + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve(source), + readFileBytesWithinLimit: () => { + reads++; + return Promise.resolve(new TextEncoder().encode(source)); + }, + }, + } as unknown as RuntimeAdapter; + + const page = await getEntityBySlug("C:\\", "about", adapter); + + assertEquals(page?.entity.content, source); + assertEquals(reads, 1); + }); + + it("rejects expired route work before filesystem access", async () => { + const adapter = createMockAdapter(); + let resolveCalls = 0; + adapter.fs.resolveFile = () => { + resolveCalls++; + return Promise.resolve(null); + }; + + await assertRejects( + () => + getEntityBySlug("/project", "about", adapter, "pages", { + deadline: 0, + }), + Error, + "deadline", + ); + assertEquals(resolveCalls, 0); + }); + + it("isolates project admission and removes an aborted queued request", async () => { + const releases: Array<{ path: string; resolve: (bytes: Uint8Array) => void }> = []; + let resolveFourActive!: () => void; + let resolveOtherProject!: () => void; + const fourActive = new Promise((resolve) => { + resolveFourActive = resolve; + }); + const otherProjectActive = new Promise((resolve) => { + resolveOtherProject = resolve; + }); + let activeReads = 0; + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: (path: string) => + new Promise((resolve) => { + releases.push({ path, resolve }); + activeReads++; + if (activeReads === 4) resolveFourActive(); + if (activeReads === 5) resolveOtherProject(); + }), + }, + } as unknown as RuntimeAdapter; + + const activeProjectA = Array.from( + { length: 4 }, + () => + getEntityBySlug("/project", "about", adapter, "pages", { + scopeKey: "project-a", + }), + ); + await fourActive; + + const abortController = new AbortController(); + const queued = getEntityBySlug("/project", "about", adapter, "pages", { + signal: abortController.signal, + scopeKey: "project-a", + }); + const projectB = getEntityBySlug("/project", "about", adapter, "pages", { + scopeKey: "project-b", + }); + await otherProjectActive; + abortController.abort(new Error("queued route cancelled")); + await assertRejects(() => queued, Error, "queued route cancelled"); + assertEquals(activeReads, 5); + + for (const release of releases) { + release.resolve(new TextEncoder().encode("# Page")); + } + assertEquals((await projectB)?.entity.content, "# Page"); + const pages = await Promise.all(activeProjectA); + assertEquals(pages.every((page) => page?.entity.content === "# Page"), true); + }); + + it("reports active deadlines while retaining permits until adapter work settles", async () => { + const readReleases: Array<(bytes: Uint8Array) => void> = []; + let resolveFourActive!: () => void; + const fourActive = new Promise((resolve) => { + resolveFourActive = resolve; + }); + let fifthStarted = false; + let resolveFifthStarted!: () => void; + const fifthStartedSignal = new Promise((resolve) => { + resolveFifthStarted = resolve; + }); + let activeReads = 0; + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: () => + new Promise((resolve) => { + readReleases.push(resolve); + activeReads++; + if (activeReads === 4) resolveFourActive(); + if (activeReads === 5) { + fifthStarted = true; + resolveFifthStarted(); + } + }), + }, + } as unknown as RuntimeAdapter; + + const deadline = Date.now() + 50; + const active = Array.from( + { length: 4 }, + () => + getEntityBySlug("/project", "about", adapter, "pages", { + deadline, + scopeKey: "deadline-project", + }), + ); + const timeoutAssertions = active.map((request) => + assertRejects(() => request, Error, "deadline") + ); + await fourActive; + await Promise.all(timeoutAssertions); + + const fifth = getEntityBySlug("/project", "about", adapter, "pages", { + deadline: Date.now() + 2_000, + scopeKey: "deadline-project", + }); + const originalReadCount = activeReads; + + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(fifthStarted, false); + assertEquals(activeReads, originalReadCount); + + readReleases.shift()!(new TextEncoder().encode("# Page")); + await fifthStartedSignal; + for (const release of readReleases.splice(0)) { + release(new TextEncoder().encode("# Page")); + } + assertEquals((await fifth)?.entity.content, "# Page"); + }); + + it("bounds isolate-wide active and queued work across project scopes", async () => { + let releaseReads!: () => void; + const readsReleased = new Promise((resolve) => { + releaseReads = resolve; + }); + let resolveGlobalActive!: () => void; + const globalActive = new Promise((resolve) => { + resolveGlobalActive = resolve; + }); + let activeReads = 0; + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: (path: string) => + Promise.resolve(path.endsWith("/pages/about") ? `${path}.mdx` : null), + readDir: async function* () {}, + readFile: () => Promise.resolve("# Page"), + readFileBytesWithinLimit: async () => { + activeReads++; + if (activeReads === 16) resolveGlobalActive(); + await readsReleased; + return new TextEncoder().encode("# Page"); + }, + }, + } as unknown as RuntimeAdapter; + + const admitted = Array.from( + { length: 80 }, + (_, index) => + getEntityBySlug("/project", "about", adapter, "pages", { + scopeKey: `global-project-${index}`, + }), + ); + await globalActive; + await new Promise((resolve) => setTimeout(resolve, 0)); + + const overflow = await assertRejects( + () => + getEntityBySlug("/project", "about", adapter, "pages", { + scopeKey: "global-project-overflow", + }), + VeryfrontError, + "Global route resolution capacity", + ); + if (!(overflow instanceof VeryfrontError)) { + throw new Error("Expected a global resolution queue error"); + } + assertEquals(overflow.slug, "dynamic-route-error"); + assertEquals(activeReads, 16); + + releaseReads(); + const pages = await Promise.all(admitted); + assertEquals(pages.every((page) => page?.entity.content === "# Page"), true); + }); + + it("resolves dynamic pages with case-insensitive supported extensions", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages", "blog"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile(join(pagesDir, "[slug].MDX"), "# Dynamic page"); + + const result = await getEntityBySlug(projectDir, "blog/entry"); + + assertExists(result); + assertEquals(result.entity.slug, "blog/entry"); + assertEquals(result.entity.content, "# Dynamic page"); + }); + }); + + it("reports ambiguous dynamic pages at the same route depth", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages", "blog"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile(join(pagesDir, "[id].mdx"), "# ID page"); + await writeTextFile(join(pagesDir, "[slug].mdx"), "# Slug page"); + + await assertRouteConflict(() => getEntityBySlug(projectDir, "blog/entry")); + }); + }); + + it("resolves routes with consecutive dynamic path segments", async () => { + await withTempDir(async (projectDir) => { + const categoryDir = join(projectDir, "pages", "blog", "[category]"); + await mkdir(categoryDir, { recursive: true }); + await writeTextFile(join(categoryDir, "[slug].mdx"), "# Nested dynamic page"); + + const result = await getEntityBySlug(projectDir, "blog/guides/getting-started"); + + assertExists(result); + assertEquals(result.entity.slug, "blog/guides/getting-started"); + assertEquals(result.entity.content, "# Nested dynamic page"); + }); + }); + + it("ignores same-priority dynamic files that are not pages", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages", "blog"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile( + join(pagesDir, "[layout].mdx"), + "---\nisLayout: true\n---\n# Dynamic layout", + ); + await writeTextFile(join(pagesDir, "[slug].mdx"), "# Dynamic page"); + + const result = await getEntityBySlug(projectDir, "blog/entry"); + + assertExists(result); + assertEquals(result.entity.content, "# Dynamic page"); + }); + }); + + it("does not treat extra filename suffixes as dynamic route syntax", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile(join(pagesDir, "[slug].draft.mdx"), "# Draft"); + + assertEquals(await getEntityBySlug(projectDir, "entry"), null); + }); + }); + + it("reports duplicate exact page definitions", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile(join(pagesDir, "about.mdx"), "# MDX page"); + await writeTextFile(join(pagesDir, "about.tsx"), "export default function About() {}"); + + await assertRouteConflict(() => getEntityBySlug(projectDir, "about")); + }); + }); + + it("reports duplicate exact pages hidden by adapter extension priority", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set("/project/pages/about.mdx", "# MDX page"); + adapter.fs.files.set( + "/project/pages/about.tsx", + "export default function About() {}", + ); + adapter.fs.resolveFile = (path: string) => + Promise.resolve( + path.endsWith("/pages/about") ? "/project/pages/about.mdx" : null, + ); + + await assertRouteConflict(() => getEntityBySlug("/project", "about", adapter)); + }); + + it("reports case-variant duplicate extensions returned by adapter directories", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set("/project/pages/about.mdx", "# Lowercase extension"); + adapter.fs.files.set("/project/pages/about.MDX", "# Uppercase extension"); + adapter.fs.resolveFile = (path: string) => + Promise.resolve( + path.endsWith("/pages/about") ? "/project/pages/about.mdx" : null, + ); + + await assertRouteConflict(() => getEntityBySlug("/project", "about", adapter)); + }); + + it("deduplicates repeated adapter directory entries deterministically", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set("/project/pages/about.mdx", "# About"); + adapter.fs.resolveFile = () => Promise.resolve("/project/pages/about.mdx"); + adapter.fs.readDir = async function* (path: string) { + if (path !== "/project/pages") return; + const entry = { + name: "about.mdx", + isFile: true, + isDirectory: false, + isSymlink: false, + }; + yield entry; + yield { ...entry }; + }; + + const result = await getEntityBySlug("/project", "about", adapter); + + assertEquals(result?.entity.content, "# About"); + }); + + it("preserves adapter directory errors during exact-page discovery", async () => { + const backendFailure = new Error("directory backend unavailable"); + const adapter = createMockAdapter(); + let resolveCalls = 0; + adapter.fs.resolveFile = (path: string) => { + resolveCalls++; + return Promise.resolve( + path.endsWith("/pages/about") ? "/project/pages/about.mdx" : null, + ); + }; + adapter.fs.readDir = () => { + throw backendFailure; + }; + + const error = await assertRejects( + () => getEntityBySlug("/project", "about", adapter), + Error, + backendFailure.message, + ); + + assertEquals(error === backendFailure, true); + assertEquals(resolveCalls, 1); + }); + + it("returns a canonical slug after resolving redundant path segments", async () => { + await withTempDir(async (projectDir) => { + const pagesDir = join(projectDir, "pages"); + await mkdir(pagesDir, { recursive: true }); + await writeTextFile(join(pagesDir, "about.mdx"), "# About"); + + const result = await getEntityBySlug(projectDir, "//./about//"); + + assertExists(result); + assertEquals(result.entity.slug, "about"); + }); + }); + + it("resolves directory index pages through adapter resolveFile", async () => { + const underlyingAdapter = {}; + const adapter = { + fs: { + symlinkSemantics: "none", + isVeryfrontAdapter: () => false, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + getAdapterType: () => "GitHubFSAdapter", + resolveFile: (path: string) => + Promise.resolve( + path.endsWith("/pages/about/index") ? "pages/about/index.mdx" : null, + ), + stat: () => + Promise.resolve({ + size: 7, + isFile: true, + isDirectory: false, + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve("# About"), + readFileBytesWithinLimit: () => Promise.resolve(new TextEncoder().encode("# About")), + readDir: async function* () {}, + }, + } as unknown as RuntimeAdapter; + + const result = await getEntityBySlug("/project", "about", adapter); + + assertExists(result); + assertEquals(result.entity.slug, "about"); + assertEquals(result.entity.content, "# About"); + }); + + it("rejects route slugs beyond the path boundary before filesystem access", async () => { + const adapter = createMockAdapter(); + let statCalls = 0; + adapter.fs.stat = () => { + statCalls++; + return Promise.reject(new Error("filesystem must not be reached")); + }; + + assertEquals( + await getEntityBySlug("/project", "x".repeat(4_097), adapter), + null, + ); + assertEquals(statCalls, 0); + }); + + it("rejects route controls before filesystem access", async () => { + const adapter = createMockAdapter(); + let statCalls = 0; + adapter.fs.stat = () => { + statCalls++; + return Promise.reject(new Error("filesystem must not be reached")); + }; + + assertEquals(await getEntityBySlug("/project", "safe\nroute", adapter), null); + assertEquals(statCalls, 0); + }); + + it("rejects composed candidate paths beyond the path boundary", async () => { + const adapter = createMockAdapter(); + let resolveCalls = 0; + let statCalls = 0; + adapter.fs.resolveFile = () => { + resolveCalls++; + return Promise.resolve(null); + }; + adapter.fs.stat = () => { + statCalls++; + return Promise.reject(new Error("filesystem must not be reached")); + }; + + assertEquals( + await getEntityBySlug( + `/${"p".repeat(3_000)}`, + "page", + adapter, + "d".repeat(1_500), + ), + null, + ); + assertEquals(resolveCalls, 0); + assertEquals(statCalls, 0); + }); + + it("rejects overlong canonical paths returned by an adapter", async () => { + const adapter = createMockAdapter(); + let statCalls = 0; + adapter.fs.resolveFile = () => Promise.resolve(`/${"x".repeat(4_097)}`); + adapter.fs.stat = () => { + statCalls++; + return Promise.reject(new Error("filesystem must not be reached")); + }; + + const error = await assertRejects( + () => getEntityBySlug("/project", "page", adapter), + VeryfrontError, + "invalid resolved path", + ); + if (!(error instanceof VeryfrontError)) { + throw new Error("Expected a VeryfrontError for an overlong resolved path"); + } + assertEquals(error.slug, "dynamic-route-error"); + assertEquals(statCalls, 0); + }); + + it("snapshots adapter directory entries before asynchronous mutation", async () => { + const underlyingAdapter = {}; + const mutableEntry = { + name: "[slug].mdx", + isFile: true, + isDirectory: false, + isSymlink: false, + }; + const adapter = { + fs: { + symlinkSemantics: "none", + isVeryfrontAdapter: () => false, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + getAdapterType: () => "GitHubFSAdapter", + resolveFile: () => Promise.resolve(null), + stat: (path: string) => + Promise.resolve({ + size: path.endsWith("/pages") ? 0 : 9, + isFile: !path.endsWith("/pages"), + isDirectory: path.endsWith("/pages"), + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve("# Dynamic"), + readFileBytesWithinLimit: () => Promise.resolve(new TextEncoder().encode("# Dynamic")), + readDir: async function* () { + yield mutableEntry; + mutableEntry.name = "changed.txt"; + }, + }, + } as unknown as RuntimeAdapter; + + const result = await getEntityBySlug("/project", "entry", adapter); + + assertExists(result); + assertEquals(result.entity.content, "# Dynamic"); + }); + + it("rejects unsafe and structurally impossible directory entries", async () => { + const createAdapter = (entry: unknown): RuntimeAdapter => { + const underlyingAdapter = {}; + return { + fs: { + isVeryfrontAdapter: () => false, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + getAdapterType: () => "GitHubFSAdapter", + resolveFile: () => Promise.resolve(null), + stat: (path: string) => + Promise.resolve({ + size: path.endsWith("/pages") ? 0 : 9, + isFile: !path.endsWith("/pages"), + isDirectory: path.endsWith("/pages"), + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve("# Dynamic"), + readDir: async function* () { + yield entry; + }, + }, + } as unknown as RuntimeAdapter; + }; + + let accessorReads = 0; + const accessorEntry = Object.defineProperties({}, { + name: { + enumerable: true, + get() { + accessorReads++; + return "[slug].mdx"; + }, + }, + isFile: { enumerable: true, value: true }, + isDirectory: { enumerable: true, value: false }, + }); + + await assertRejects( + () => getEntityBySlug("/project", "entry", createAdapter(accessorEntry)), + Error, + "invalid directory entry", + ); + assertEquals(accessorReads, 0); + + const inheritedEntry = Object.create({ + name: "[slug].mdx", + isFile: true, + isDirectory: false, + }); + await assertRejects( + () => getEntityBySlug("/project", "entry", createAdapter(inheritedEntry)), + Error, + "invalid directory entry", + ); + + assertEquals( + await getEntityBySlug( + "/project", + "entry", + createAdapter({ + name: "[slug]\n.mdx", + isFile: true, + isDirectory: false, + }), + ), + null, + ); + + assertEquals( + await getEntityBySlug( + "/project", + "entry", + createAdapter({ + name: `[${"x".repeat(4_097)}].mdx`, + isFile: true, + isDirectory: false, + }), + ), + null, + ); + + await assertRejects( + () => + getEntityBySlug( + "/project", + "entry", + createAdapter({ + name: "[slug].mdx", + isFile: true, + isDirectory: true, + }), + ), + Error, + "invalid directory entry", + ); + }); + + it("bounds adapter directory iteration", async () => { + const underlyingAdapter = {}; + const adapter = { + fs: { + isVeryfrontAdapter: () => false, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + getAdapterType: () => "GitHubFSAdapter", + resolveFile: () => Promise.resolve(null), + stat: () => + Promise.resolve({ + size: 0, + isFile: false, + isDirectory: true, + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve(""), + readDir: async function* () { + for (let index = 0; index <= 2_048; index++) { + yield { + name: `entry-${index}`, + isFile: false, + isDirectory: true, + isSymlink: false, + }; + } + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "entry", adapter), + Error, + "directory entries", + ); + }); + + it("rejects excessive matching routes before reading candidate sources", async () => { + let sourceReads = 0; + const adapter = { + fs: { + symlinkSemantics: "none", + resolveFile: () => Promise.resolve(null), + readFile: () => Promise.resolve("# Dynamic"), + readFileBytesWithinLimit: () => { + sourceReads++; + return Promise.resolve(new TextEncoder().encode("# Dynamic")); + }, + readDir: async function* (path: string) { + if (!path.endsWith("/pages")) return; + for (let index = 0; index < 33; index++) { + yield { + name: `[parameter${index}].mdx`, + isFile: true, + isDirectory: false, + isSymlink: false, + }; + } + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "entry", adapter), + Error, + "32-candidate limit", + ); + assertEquals(sourceReads, 0); + }); + + it("charges invalid entries against the global dynamic traversal budget", async () => { + const adapter = { + id: "memory", + fs: { + resolveFile: () => Promise.resolve(null), + stat: () => + Promise.resolve({ + size: 0, + isFile: false, + isDirectory: true, + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve(""), + readDir: async function* () { + for (let index = 0; index < 2_000; index++) { + yield { + name: "..", + isFile: false, + isDirectory: true, + isSymlink: false, + }; + } + yield { + name: "[segment]", + isFile: false, + isDirectory: true, + isSymlink: false, + }; + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", Array(11).fill("part").join("/"), adapter), + Error, + "8192-entry limit", + ); + }); + + it("bounds dynamic directory traversal across one route lookup", async () => { + const underlyingAdapter = {}; + const adapter = { + fs: { + isVeryfrontAdapter: () => false, + getUnderlyingAdapter: () => underlyingAdapter, + isMultiProjectMode: () => false, + getAdapterType: () => "GitHubFSAdapter", + resolveFile: () => Promise.resolve(null), + stat: () => + Promise.resolve({ + size: 0, + isFile: false, + isDirectory: true, + isSymlink: false, + mtime: null, + }), + readFile: () => Promise.resolve(""), + readDir: async function* (path: string) { + if (!path.endsWith("/pages")) return; + for (let index = 0; index <= 1_024; index++) { + yield { + name: `[segment${index}]`, + isFile: false, + isDirectory: true, + isSymlink: false, + }; + } + }, + }, + } as unknown as RuntimeAdapter; + + await assertRejects( + () => getEntityBySlug("/project", "entry", adapter), + Error, + "candidate limit", + ); + }); +}); + +describe("getLayoutEntity", () => { + it("resolves explicit layout paths with case-insensitive supported extensions", async () => { + await withTempDir(async (projectDir) => { + const layoutPath = join(projectDir, "components", "DefaultLayout.MDX"); + await mkdir(join(projectDir, "components"), { recursive: true }); + await writeTextFile(layoutPath, "---\nisLayout: true\n---\n# Default layout"); + + const result = await getLayoutEntity( + projectDir, + "components/DefaultLayout.MDX", + ); + + assertExists(result); + assertEquals(result.entity.isLayout, true); + assertEquals(result.entity.content, "# Default layout"); + }); + }); + + it("applies the layouts-directory convention to explicit file paths", async () => { + await withTempDir(async (projectDir) => { + const layoutPath = join(projectDir, "layouts", "main.mdx"); + await mkdir(join(projectDir, "layouts"), { recursive: true }); + await writeTextFile(layoutPath, "# Main layout"); + + const result = await getLayoutEntity(projectDir, "layouts/main.mdx"); + + assertExists(result); + assertEquals(result.entity.type, "layout"); + assertEquals(result.entity.content, "# Main layout"); + }); + }); + + it("does not apply the layout convention to explicit page paths", async () => { + await withTempDir(async (projectDir) => { + const pagePath = join(projectDir, "pages", "main.mdx"); + await mkdir(join(projectDir, "pages"), { recursive: true }); + await writeTextFile(pagePath, "# Main page"); + + assertEquals( + await getLayoutEntity(projectDir, "pages/main.mdx"), + null, + ); + }); + }); + + it("reports duplicate layout definitions", async () => { + await withTempDir(async (projectDir) => { + const layoutsDirectory = join(projectDir, "layouts"); + await mkdir(layoutsDirectory, { recursive: true }); + await writeTextFile(join(layoutsDirectory, "main.mdx"), "# MDX layout"); + await writeTextFile(join(layoutsDirectory, "main.tsx"), "export default () => null;"); + + await assertRouteConflict(() => getLayoutEntity(projectDir, "main")); + }); + }); +}); diff --git a/tests/integration/core/getEntityInfo.test.ts b/tests/integration/core/getEntityInfo.test.ts index 7a45e3e783..87517085e4 100644 --- a/tests/integration/core/getEntityInfo.test.ts +++ b/tests/integration/core/getEntityInfo.test.ts @@ -99,7 +99,7 @@ Layout content`, const file2 = join(pagesDir, "index.mdx"); await createTestFile(file2, "# Home"); - const info2 = await getEntityInfo(file2); + const info2 = await getEntityInfo(file2, undefined, { routeRoot: pagesDir }); assertExists(info2); assertEquals(info2.entity.slug, "");