diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 45e68b4494..54e42b5983 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -28,7 +28,6 @@ "src/build/production-build/manifest.test.ts", "src/build/renderer/services/css-bundler.test.ts", "src/cache/registry.test.ts", - "src/config/env.test.ts", "src/embedding/chunk.test.ts", "src/embedding/rag-store.test.ts", "src/eval/judges.test.ts", @@ -70,7 +69,6 @@ "src/security/path-validation/index.test.ts", "src/server/build-service-worker.test.ts", "src/server/handlers/response/cors.test.ts", - "src/server/shared/renderer/adapter.test.ts", "src/transforms/import-rewriter/strategies/import-map-strategy.test.ts", "src/transforms/md/compiler/md-compiler.test.ts", "src/transforms/mdx/compiler/index.test.ts", diff --git a/src/build/production-build/templates.ts b/src/build/production-build/templates.ts index 024d325b70..5dfaf5ab68 100644 --- a/src/build/production-build/templates.ts +++ b/src/build/production-build/templates.ts @@ -14,4 +14,4 @@ export const CLIENT_ROUTER_BUNDLE: string | undefined = 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar rendererLogger = logger;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * 1024;\n\n// src/rendering/client/navigation-store.ts\nvar STORE_KEY = /* @__PURE__ */ Symbol.for("veryfront.navigation.store.v1");\nfunction getNavigationStore() {\n const holder = globalThis;\n const existing = holder[STORE_KEY];\n if (existing) return existing;\n const listeners = /* @__PURE__ */ new Set();\n let navigator = null;\n const store = {\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getHref() {\n const loc = globalThis.location;\n return loc ? `${loc.pathname}${loc.search}${loc.hash}` : "/";\n },\n notify() {\n for (const listener of [...listeners]) {\n try {\n listener();\n } catch {\n }\n }\n },\n navigate(href, options) {\n if (navigator) return navigator(href, options);\n globalThis.location?.assign(href);\n return Promise.resolve();\n },\n setNavigator(next) {\n navigator = next;\n }\n };\n holder[STORE_KEY] = store;\n return store;\n}\n\n// src/rendering/client/router.ts\nimport ReactDOM from "react-dom/client";\n\n// src/html/managed-head-protocol.ts\nvar HEAD_PROVENANCE_ATTRIBUTE = "data-vf-head";\nvar HEAD_LEGACY_MANAGED_ATTRIBUTE = "data-veryfront-managed";\nvar HEAD_CONTENT_HASH_ATTRIBUTE = "data-vf-hash";\nvar HEAD_REACT_MANAGED_ATTRIBUTE = "data-vf-react-head";\nvar HEAD_REACT_OWNER_ATTRIBUTE = "data-vf-react-head-owner";\nvar HEAD_ROUTE_MANAGED_ATTRIBUTE = "data-vf-route-head";\nvar HEAD_SHELL_PROVENANCE_ATTRIBUTE = "data-vf-shell-head";\nvar HEAD_SSR_PAYLOAD_ATTRIBUTE = "data-vf-ssr-head";\nvar MAX_MANAGED_HEAD_ENTRIES = 128;\nvar MAX_MANAGED_HEAD_BYTES = 2 * 1024 * 1024;\nvar REACT_HEAD_ATTRIBUTE_NAMES = {\n charSet: "charset",\n className: "class",\n crossOrigin: "crossorigin",\n fetchPriority: "fetchpriority",\n htmlFor: "for",\n httpEquiv: "http-equiv",\n imageSizes: "imagesizes",\n imageSrcSet: "imagesrcset",\n noModule: "nomodule",\n referrerPolicy: "referrerpolicy"\n};\nvar SINGLETON_META_KEYS = /* @__PURE__ */ new Set([\n "description",\n "robots",\n "viewport",\n "referrer",\n "color-scheme",\n "application-name",\n "generator",\n "og:title",\n "og:description",\n "og:url",\n "og:type",\n "og:site_name",\n "og:locale",\n "twitter:card",\n "twitter:site",\n "twitter:creator",\n "twitter:title",\n "twitter:description",\n "twitter:image",\n "twitter:image:alt"\n]);\nvar SINGLETON_LINK_RELS = /* @__PURE__ */ new Set([\n "canonical",\n "manifest",\n "amphtml"\n]);\nvar SUPPORTED_MANAGED_HEAD_TAGS = /* @__PURE__ */ new Set([\n "title",\n "meta",\n "link",\n "style",\n "script"\n]);\nvar HEAD_ATTRIBUTE_NAME_PATTERN = /^[A-Za-z_:][A-Za-z0-9_.:-]*$/;\nvar MAX_HEAD_PROP_ENTRIES = 128;\nvar MAX_HEAD_ATTRIBUTE_NAME_BYTES = 256;\nvar MAX_HEAD_ATTRIBUTE_VALUE_BYTES = 64 * 1024;\nvar MAX_HEAD_ATTRIBUTE_BYTES = 1024 * 1024;\nvar MAX_HEAD_CONTENT_BYTES = 1024 * 1024;\nvar headTextEncoder = new TextEncoder();\nvar BOOLEAN_HEAD_ATTRIBUTES = /* @__PURE__ */ new Set([\n "async",\n "defer",\n "disabled",\n "itemscope",\n "nomodule"\n]);\nfunction isHeadFrameworkAttribute(name) {\n switch (name.toLowerCase()) {\n case HEAD_PROVENANCE_ATTRIBUTE:\n case HEAD_LEGACY_MANAGED_ATTRIBUTE:\n case HEAD_CONTENT_HASH_ATTRIBUTE:\n case HEAD_REACT_MANAGED_ATTRIBUTE:\n case HEAD_REACT_OWNER_ATTRIBUTE:\n case HEAD_ROUTE_MANAGED_ATTRIBUTE:\n case HEAD_SHELL_PROVENANCE_ATTRIBUTE:\n case HEAD_SSR_PAYLOAD_ATTRIBUTE:\n return true;\n default:\n return false;\n }\n}\nfunction normalizeHeadIdentityValue(value) {\n const normalized = value?.trim().toLowerCase();\n return normalized || void 0;\n}\nfunction readOwnString(record, key) {\n try {\n const descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n return descriptor && !descriptor.get && !descriptor.set && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : void 0;\n } catch {\n return void 0;\n }\n}\nfunction headMetaSingletonKeyFromRecord(meta) {\n if (readOwnString(meta, "charset") !== void 0) return "meta:charset";\n const key = normalizeHeadIdentityValue(\n readOwnString(meta, "property") ?? readOwnString(meta, "name")\n );\n if (!key) return void 0;\n if (key === "theme-color") {\n return `meta:theme-color:${readOwnString(meta, "media")?.trim() ?? ""}`;\n }\n return SINGLETON_META_KEYS.has(key) ? `meta:${key}` : void 0;\n}\nfunction headLinkSingletonKeyFromRecord(link) {\n const rel = normalizeHeadIdentityValue(readOwnString(link, "rel"));\n return rel && SINGLETON_LINK_RELS.has(rel) ? `link:${rel}` : void 0;\n}\nfunction normalizeManagedHeadString(value) {\n return value.replace(/\\r\\n?/g, "\\n");\n}\nfunction inspectHeadProps(value) {\n if (typeof value !== "object" || value === null || Array.isArray(value)) return null;\n let prototype;\n let keys;\n try {\n prototype = Object.getPrototypeOf(value);\n keys = Reflect.ownKeys(value);\n } catch {\n return null;\n }\n if (prototype !== Object.prototype && prototype !== null) return null;\n const inspected = /* @__PURE__ */ new Map();\n let entries = 0;\n for (const key of keys) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(value, key);\n } catch {\n return null;\n }\n if (!descriptor) return null;\n if (!descriptor.enumerable) continue;\n if (typeof key !== "string" || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return null;\n }\n entries++;\n if (entries > MAX_HEAD_PROP_ENTRIES) return null;\n inspected.set(key, descriptor.value);\n }\n return inspected;\n}\nfunction normalizeContentPrimitive(value) {\n if (value === null || value === void 0 || typeof value === "boolean") return void 0;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n return null;\n }\n const content = normalizeManagedHeadString(String(value));\n return headTextEncoder.encode(content).byteLength <= MAX_HEAD_CONTENT_BYTES ? content : null;\n}\nfunction normalizeManagedHeadAttributesFromProps(tagName, props, ambientNonce, excludedKeys = /* @__PURE__ */ new Set()) {\n const attributeMap = /* @__PURE__ */ new Map();\n for (const [key, value] of props) {\n if (key === "children" || key === "dangerouslySetInnerHTML" || excludedKeys.has(key)) {\n continue;\n }\n if (/^on/i.test(key) || typeof value === "function" || typeof value === "symbol" || typeof value === "object") {\n continue;\n }\n const name = (REACT_HEAD_ATTRIBUTE_NAMES[key] ?? key).toLowerCase();\n if (isHeadFrameworkAttribute(name) || !HEAD_ATTRIBUTE_NAME_PATTERN.test(name) || headTextEncoder.encode(name).byteLength > MAX_HEAD_ATTRIBUTE_NAME_BYTES) {\n continue;\n }\n if (BOOLEAN_HEAD_ATTRIBUTES.has(name)) {\n if (value !== false && value !== void 0) attributeMap.set(name, "");\n continue;\n }\n if (typeof value === "boolean") {\n if (name.startsWith("data-") || name.startsWith("aria-")) {\n attributeMap.set(name, String(value));\n }\n continue;\n }\n if (value === void 0) continue;\n if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") {\n continue;\n }\n const normalizedValue = normalizeManagedHeadString(String(value));\n if (headTextEncoder.encode(normalizedValue).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) {\n return null;\n }\n attributeMap.set(name, normalizedValue);\n }\n if ((tagName === "script" || tagName === "style") && ambientNonce) {\n const nonce = normalizeManagedHeadString(ambientNonce);\n if (headTextEncoder.encode(nonce).byteLength > MAX_HEAD_ATTRIBUTE_VALUE_BYTES) return null;\n attributeMap.set("nonce", nonce);\n }\n if (tagName === "link" && attributeMap.get("rel")?.trim().toLowerCase() === "preload" && attributeMap.get("as")?.trim().toLowerCase() === "font" && !attributeMap.has("crossorigin")) {\n attributeMap.set("crossorigin", "anonymous");\n }\n if (attributeMap.size > MAX_HEAD_PROP_ENTRIES) return null;\n let totalBytes = 0;\n for (const [name, value] of attributeMap) {\n totalBytes += headTextEncoder.encode(name).byteLength + headTextEncoder.encode(value).byteLength;\n if (totalBytes > MAX_HEAD_ATTRIBUTE_BYTES) return null;\n }\n return [...attributeMap.entries()].sort(([left], [right]) => left.localeCompare(right));\n}\nfunction singletonKey(tagName, attributes) {\n if (tagName === "title") return "title";\n const record = Object.fromEntries(attributes);\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(record);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(record);\n return void 0;\n}\nfunction scriptKeys(tagName, attributes) {\n if (tagName !== "script") return [];\n const keys = [];\n const id = attributes.get("id");\n const src = attributes.get("src");\n if (id) keys.push(`script:id:${id}`);\n if (src) keys.push(`script:src:${src}`);\n return keys;\n}\nfunction declaresDocumentEncoding(attributes) {\n return attributes.has("charset") || attributes.get("http-equiv")?.trim().toLowerCase() === "content-type";\n}\nfunction createManagedHeadDescriptor(tagName, attributes, content, contentMode) {\n const attributeMap = new Map(attributes);\n return {\n tagName,\n attributes,\n ...content !== void 0 && { content },\n contentMode,\n signature: JSON.stringify([\n tagName,\n attributes,\n contentMode,\n content ?? null\n ]),\n singletonKey: singletonKey(tagName, attributeMap),\n scriptKeys: scriptKeys(tagName, attributeMap)\n };\n}\nfunction descriptorFromManagedHeadRecord(rawTagName, record, options = {}) {\n const tagName = rawTagName.toLowerCase();\n if (!SUPPORTED_MANAGED_HEAD_TAGS.has(tagName)) return null;\n const inspected = inspectHeadProps(record);\n if (!inspected) return null;\n const excludedKeys = options.contentProperty ? /* @__PURE__ */ new Set([options.contentProperty]) : /* @__PURE__ */ new Set();\n const attributes = normalizeManagedHeadAttributesFromProps(\n tagName,\n inspected,\n options.ambientNonce,\n excludedKeys\n );\n if (!attributes) return null;\n const attributeMap = new Map(attributes);\n if (tagName === "meta" && declaresDocumentEncoding(attributeMap)) return null;\n if ((tagName === "meta" || tagName === "link") && attributes.length === 0) return null;\n let content;\n if (options.contentProperty) {\n const normalized = normalizeContentPrimitive(inspected.get(options.contentProperty));\n if (normalized === null) return null;\n content = normalized;\n }\n return createManagedHeadDescriptor(tagName, attributes, content, "text");\n}\nfunction headScriptKeysIntersect(left, right) {\n if (left.length === 0 || right.length === 0) return false;\n const rightKeys = new Set(right);\n return left.some((key) => rightKeys.has(key));\n}\nfunction aggregateManagedHeadDescriptors(descriptors) {\n const aggregated = [];\n const singletonIndexes = /* @__PURE__ */ new Map();\n const scriptKeysSeen = /* @__PURE__ */ new Set();\n for (const descriptor of descriptors) {\n if (descriptor.singletonKey) {\n const index = singletonIndexes.get(descriptor.singletonKey);\n if (index !== void 0) {\n aggregated[index] = descriptor;\n continue;\n }\n singletonIndexes.set(descriptor.singletonKey, aggregated.length);\n } else if (descriptor.scriptKeys.length > 0) {\n if (descriptor.scriptKeys.some((key) => scriptKeysSeen.has(key))) continue;\n for (const key of descriptor.scriptKeys) scriptKeysSeen.add(key);\n }\n aggregated.push(descriptor);\n }\n return aggregated;\n}\nfunction managedHeadDescriptorBytes(descriptor) {\n let bytes = headTextEncoder.encode(descriptor.tagName).byteLength;\n for (const [name, value] of descriptor.attributes) {\n bytes += headTextEncoder.encode(name).byteLength;\n bytes += headTextEncoder.encode(value).byteLength;\n }\n if (descriptor.content !== void 0) {\n bytes += headTextEncoder.encode(descriptor.content).byteLength;\n }\n return bytes;\n}\nfunction assertManagedHeadDescriptorBudget(descriptors) {\n if (descriptors.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_ENTRIES}-entry request limit`\n );\n }\n let bytes = 0;\n for (const descriptor of descriptors) {\n bytes += managedHeadDescriptorBytes(descriptor);\n if (bytes > MAX_MANAGED_HEAD_BYTES) {\n throw new TypeError(\n `Managed head exceeds the ${MAX_MANAGED_HEAD_BYTES}-byte request limit`\n );\n }\n }\n}\nfunction managedHeadDescriptorToTransportEntry(descriptor) {\n const attributes = descriptor.attributes.filter(([name]) => name !== "nonce");\n return {\n tagName: descriptor.tagName,\n attributes: attributes.map(([name, value]) => [name, value]),\n ...descriptor.content !== void 0 && { content: descriptor.content }\n };\n}\nfunction ownTransportValue(record, key) {\n let descriptor;\n try {\n descriptor = Reflect.getOwnPropertyDescriptor(record, key);\n } catch {\n return void 0;\n }\n if (!descriptor || descriptor.get || descriptor.set || !("value" in descriptor)) {\n return void 0;\n }\n return descriptor.value;\n}\nfunction descriptorFromManagedHeadTransportEntry(entry, ambientNonce) {\n if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n let prototype;\n try {\n prototype = Object.getPrototypeOf(entry);\n } catch {\n throw new TypeError("Managed-head transport entry cannot be inspected");\n }\n if (prototype !== Object.prototype && prototype !== null) {\n throw new TypeError("Managed-head transport entries must be plain objects");\n }\n const tagName = ownTransportValue(entry, "tagName");\n const rawAttributes = ownTransportValue(entry, "attributes");\n const content = ownTransportValue(entry, "content");\n if (typeof tagName !== "string" || tagName !== tagName.toLowerCase() || !Array.isArray(rawAttributes)) {\n throw new TypeError("Managed-head transport entry is not canonical");\n }\n if (rawAttributes.length > MAX_HEAD_PROP_ENTRIES) {\n throw new TypeError("Managed-head transport entry exceeds the attribute limit");\n }\n if (content !== void 0 && typeof content !== "string") {\n throw new TypeError("Managed-head transport content must be a string");\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (!supportsText && content !== void 0) {\n throw new TypeError("Managed-head transport content is invalid for this tag");\n }\n const record = /* @__PURE__ */ Object.create(null);\n const inputAttributes = [];\n const names = /* @__PURE__ */ new Set();\n for (let index = 0; index < rawAttributes.length; index += 1) {\n const pair = ownTransportValue(rawAttributes, String(index));\n if (!Array.isArray(pair) || pair.length !== 2) {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const name = ownTransportValue(pair, "0");\n const value = ownTransportValue(pair, "1");\n if (typeof name !== "string" || typeof value !== "string") {\n throw new TypeError("Managed-head transport attributes must be string pairs");\n }\n const normalizedName = name.toLowerCase();\n if (name !== normalizedName || normalizedName === "nonce" || names.has(normalizedName)) {\n throw new TypeError("Managed-head transport attributes are not canonical");\n }\n names.add(normalizedName);\n inputAttributes.push([normalizedName, value]);\n Object.defineProperty(record, normalizedName, {\n enumerable: true,\n value\n });\n }\n if (content !== void 0) {\n Object.defineProperty(record, "__veryfront_transport_content", {\n enumerable: true,\n value: content\n });\n }\n const descriptor = descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: "__veryfront_transport_content" },\n ...(tagName === "script" || tagName === "style") && ambientNonce ? { ambientNonce } : {}\n });\n const normalizedInput = inputAttributes.sort(([left], [right]) => left.localeCompare(right));\n const normalizedOutput = descriptor?.attributes.filter(([name]) => name !== "nonce");\n if (!descriptor || JSON.stringify(normalizedOutput) !== JSON.stringify(normalizedInput) || supportsText && (descriptor.content ?? "") !== (content ?? "")) {\n throw new TypeError("Managed-head transport entry failed validation");\n }\n return descriptor;\n}\nvar BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";\nfunction decodeBase64Url(value) {\n if (value.length % 4 === 1 || !/^[A-Za-z0-9_-]*$/.test(value)) {\n throw new TypeError("Managed-head payload is not valid base64url");\n }\n const estimatedBytes = Math.floor(value.length * 3 / 4);\n if (estimatedBytes > MAX_MANAGED_HEAD_BYTES * 2) {\n throw new TypeError("Managed-head payload exceeds its encoded size limit");\n }\n const bytes = new Uint8Array(estimatedBytes);\n let outputIndex = 0;\n let buffer = 0;\n let bits = 0;\n for (const character of value) {\n const decoded = BASE64URL_ALPHABET.indexOf(character);\n if (decoded < 0) throw new TypeError("Managed-head payload is not valid base64url");\n buffer = buffer << 6 | decoded;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n bytes[outputIndex++] = buffer >> bits & 255;\n buffer &= bits === 0 ? 0 : (1 << bits) - 1;\n }\n }\n if (bits > 0 && buffer !== 0) {\n throw new TypeError("Managed-head payload has non-canonical trailing bits");\n }\n return bytes.subarray(0, outputIndex);\n}\nfunction deserializeManagedHeadPayload(payload, ambientNonce) {\n if (typeof payload !== "string") throw new TypeError("Managed-head payload must be a string");\n let decoded;\n try {\n decoded = new TextDecoder("utf-8", { fatal: true }).decode(decodeBase64Url(payload));\n } catch (error) {\n if (error instanceof TypeError) throw error;\n throw new TypeError("Managed-head payload is not valid UTF-8", { cause: error });\n }\n let entries;\n try {\n entries = JSON.parse(decoded);\n } catch (error) {\n throw new TypeError("Managed-head payload is not valid JSON", { cause: error });\n }\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Managed-head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map((entry) => descriptorFromManagedHeadTransportEntry(entry, ambientNonce))\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\n\n// src/html/client-head-manager.ts\nvar HEAD_MANAGER_STATE_SYMBOL = /* @__PURE__ */ Symbol.for(\n "veryfront.client-head-manager.v2"\n);\nvar CROSS_PAGE_PRESERVED_SINGLETON_KEYS = /* @__PURE__ */ new Set([\n "meta:viewport",\n "link:manifest"\n]);\nfunction getClientHeadManagerState() {\n const globalState = globalThis;\n return globalState[HEAD_MANAGER_STATE_SYMBOL] ?? (globalState[HEAD_MANAGER_STATE_SYMBOL] = {\n documents: /* @__PURE__ */ new WeakMap()\n });\n}\nfunction getManagedHeadNonce(targetDocument) {\n if (typeof targetDocument.querySelector !== "function") return void 0;\n const element = targetDocument.querySelector(\n "script[nonce], style[nonce], link[nonce]"\n );\n if (!element) return void 0;\n const nonce = element.nonce || element.getAttribute("nonce") || "";\n return nonce || void 0;\n}\nfunction readElementAttributes(element) {\n const attributes = [];\n for (const attribute of element.attributes) {\n const name = attribute.name.toLowerCase();\n if (isHeadFrameworkAttribute(name)) continue;\n const nonce = name === "nonce" && "nonce" in element ? element.nonce : "";\n const value = BOOLEAN_HEAD_ATTRIBUTES.has(name) ? "" : nonce || attribute.value;\n attributes.push([name, value]);\n }\n return attributes.sort(([left], [right]) => left.localeCompare(right));\n}\nfunction elementSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n const attributes = Object.fromEntries(readElementAttributes(element));\n if (tagName === "meta") return headMetaSingletonKeyFromRecord(attributes);\n if (tagName === "link") return headLinkSingletonKeyFromRecord(attributes);\n return void 0;\n}\nfunction promoteToShellHeadBaseline(element) {\n for (const attribute of [...element.attributes]) {\n if (isHeadFrameworkAttribute(attribute.name)) {\n element.removeAttribute(attribute.name);\n }\n }\n element.setAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE, "true");\n}\nfunction isCrossPagePreservedSingleton(element, singletonKey2 = elementSingletonKey(element)) {\n return element.parentElement !== null && singletonKey2 !== void 0 && CROSS_PAGE_PRESERVED_SINGLETON_KEYS.has(singletonKey2);\n}\nfunction isFrameworkOwnedHeadElement(element) {\n return element.getAttribute(HEAD_PROVENANCE_ATTRIBUTE) === "true" || element.getAttribute(HEAD_REACT_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1" || element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true";\n}\nfunction retireFrameworkHeadElement(element) {\n if (isCrossPagePreservedSingleton(element)) {\n promoteToShellHeadBaseline(element);\n return;\n }\n element.remove();\n}\nfunction retireClientHeadOwnership(targetDocument) {\n const manager = getClientHeadManagerState().documents.get(targetDocument);\n if (manager) {\n manager.retire();\n return;\n }\n for (const element of [...targetDocument.head?.children ?? []]) {\n if (isFrameworkOwnedHeadElement(element)) retireFrameworkHeadElement(element);\n }\n}\n\n// src/html/client-route-head.ts\nvar ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_route_head_content";\nfunction descriptorFromHeadElement(element) {\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name)) record[name] = value;\n }\n const tagName = element.tagName.toLowerCase();\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(\n tagName,\n record,\n supportsText ? { contentProperty: ROUTE_HEAD_CONTENT_PROPERTY } : void 0\n );\n}\nfunction writeRouteDescriptor(element, descriptor) {\n for (const attribute of [...element.attributes]) element.removeAttribute(attribute.name);\n for (const [name, value] of descriptor.attributes) element.setAttribute(name, value);\n element.textContent = descriptor.content ?? "";\n element.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n element.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n}\nfunction prepareClientRouteHeadEntries(entries, targetDocument = document) {\n if (entries === void 0) return [];\n if (!Array.isArray(entries) || entries.length > MAX_MANAGED_HEAD_ENTRIES) {\n throw new TypeError("Route head payload exceeds the entry limit");\n }\n const descriptors = aggregateManagedHeadDescriptors(\n entries.map(\n (entry) => descriptorFromManagedHeadTransportEntry(entry, getManagedHeadNonce(targetDocument))\n )\n );\n assertManagedHeadDescriptorBudget(descriptors);\n return descriptors;\n}\nfunction applyPreparedClientRouteHeadDescriptors(descriptors, targetDocument = document) {\n for (const descriptor of descriptors) {\n const described = [...targetDocument.head.children].flatMap((element2) => {\n const current = descriptorFromHeadElement(element2);\n return current ? [{ element: element2, descriptor: current }] : [];\n });\n if (descriptor.singletonKey) {\n const matches = described.filter(\n ({ descriptor: current }) => current.singletonKey === descriptor.singletonKey\n );\n const directive = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1"\n );\n if (directive) {\n continue;\n }\n const reusable = matches.find(\n ({ element: element2 }) => element2.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true" || element2.getAttribute(HEAD_SHELL_PROVENANCE_ATTRIBUTE) === "true"\n );\n if (reusable) {\n writeRouteDescriptor(reusable.element, descriptor);\n continue;\n }\n }\n if (described.some(\n ({ descriptor: current }) => current.signature === descriptor.signature || headScriptKeysIntersect(current.scriptKeys, descriptor.scriptKeys)\n )) {\n continue;\n }\n const element = targetDocument.createElement(descriptor.tagName);\n writeRouteDescriptor(element, descriptor);\n targetDocument.head.appendChild(element);\n }\n}\nfunction updateRouteTitle(title, targetDocument = document) {\n if (typeof title !== "string" || !title) return;\n const titles = [...targetDocument.head.querySelectorAll("title")];\n if (titles.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let titleElement = titles.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n for (const element of titles) {\n if (element !== titleElement) element.remove();\n }\n if (!titleElement) {\n titleElement = targetDocument.createElement("title");\n titleElement.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(titleElement);\n }\n titleElement.textContent = title;\n}\nfunction updateRouteMetaTag(targetDocument, selector, attributeName, attributeValue, content) {\n const matches = [...targetDocument.head.querySelectorAll(selector)];\n if (matches.some((element) => element.getAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE) === "1")) {\n return;\n }\n let metaTag = matches.find(\n (element) => element.getAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE) === "true"\n );\n if (!metaTag) {\n metaTag = targetDocument.createElement("meta");\n metaTag.setAttribute(attributeName, attributeValue);\n metaTag.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(metaTag);\n }\n metaTag.setAttribute("content", content);\n}\nfunction updateRouteMetaTags(metadata, targetDocument = document) {\n if (typeof metadata.description === "string" && metadata.description) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[name="description"]\',\n "name",\n "description",\n metadata.description\n );\n }\n if (typeof metadata.ogTitle === "string" && metadata.ogTitle) {\n updateRouteMetaTag(\n targetDocument,\n \'meta[property="og:title"]\',\n "property",\n "og:title",\n metadata.ogTitle\n );\n }\n}\n\n// src/routing/client/dom-utils.ts\nvar logger2 = rendererLogger.component("veryfront");\nvar PARSED_ROUTE_HEAD_CONTENT_PROPERTY = "__veryfront_parsed_route_head_content";\nfunction isInternalLink(target) {\n const href = target.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) return false;\n const linkTarget = target.getAttribute("target");\n if (linkTarget === "_blank" || target.hasAttribute("download")) return false;\n return true;\n}\nfunction findAnchorElement(element) {\n let current = element;\n while (current && current.tagName !== "A") {\n current = current.parentElement;\n }\n return current instanceof HTMLAnchorElement ? current : null;\n}\nfunction applyHeadDirectives(container) {\n const targetDocument = container.ownerDocument ?? document;\n const nodes = [...container.querySelectorAll(\'[data-veryfront-head="1"], vf-head\')].filter(\n (node) => typeof node.getAttribute !== "function" || node.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1"\n );\n if (!nodes.length) return;\n retireClientHeadOwnership(targetDocument);\n cleanManagedHeadTags(targetDocument);\n for (const wrapper of nodes) {\n const TemplateElement = targetDocument.defaultView?.HTMLTemplateElement ?? globalThis.HTMLTemplateElement;\n const contentSource = TemplateElement && wrapper instanceof TemplateElement ? wrapper.content : wrapper;\n processHeadWrapper(contentSource, targetDocument);\n wrapper.parentElement?.removeChild(wrapper);\n }\n}\nfunction cleanManagedHeadTags(targetDocument) {\n for (const element of targetDocument.head.querySelectorAll(\n `[${HEAD_LEGACY_MANAGED_ATTRIBUTE}="1"]`\n )) {\n element.parentElement?.removeChild(element);\n }\n}\nfunction processHeadWrapper(wrapper, targetDocument) {\n const ElementConstructor = targetDocument.defaultView?.Element ?? globalThis.Element;\n const activeNonce = getManagedHeadNonce(targetDocument);\n for (const node of wrapper.childNodes) {\n if (!ElementConstructor || !(node instanceof ElementConstructor)) continue;\n const tagName = node.tagName.toLowerCase();\n if (headSingletonKey(node) === "meta:charset") continue;\n const clone = targetDocument.createElement(tagName);\n for (const { name, value } of node.attributes) {\n if (name.toLowerCase() !== "nonce") clone.setAttribute(name, value);\n }\n if (activeNonce && (tagName === "script" || tagName === "style" || tagName === "link")) {\n clone.setAttribute("nonce", activeNonce);\n }\n if (node.textContent && !clone.hasAttribute("src")) {\n clone.textContent = node.textContent;\n }\n replaceExistingHeadSingleton(targetDocument, clone);\n clone.setAttribute(HEAD_LEGACY_MANAGED_ATTRIBUTE, "1");\n clone.setAttribute(HEAD_ROUTE_MANAGED_ATTRIBUTE, "true");\n targetDocument.head.appendChild(clone);\n }\n}\nfunction headSingletonKey(element) {\n const tagName = element.tagName.toLowerCase();\n if (tagName === "title") return "title";\n if (tagName !== "meta" && tagName !== "link") return void 0;\n const attributes = /* @__PURE__ */ Object.create(null);\n if (!element.attributes) return void 0;\n for (const { name, value } of element.attributes) attributes[name.toLowerCase()] = value;\n if (tagName === "meta" && attributes["http-equiv"]?.trim().toLowerCase() === "content-type") {\n return "meta:charset";\n }\n return tagName === "meta" ? headMetaSingletonKeyFromRecord(attributes) : headLinkSingletonKeyFromRecord(attributes);\n}\nfunction replaceExistingHeadSingleton(targetDocument, replacement) {\n const singletonKey2 = headSingletonKey(replacement);\n if (!singletonKey2 || singletonKey2 === "meta:charset") return;\n for (const existing of [...targetDocument.head?.children ?? []]) {\n if (headSingletonKey(existing) === singletonKey2) existing.remove();\n }\n}\nfunction manageFocus(container) {\n try {\n const focusElement = container.querySelector("[data-router-focus]") || container.querySelector("main") || container.querySelector("h1");\n focusElement?.focus?.({ preventScroll: true });\n } catch (error) {\n logger2.warn("focus management failed", error);\n }\n}\nfunction extractPageDataFromScript() {\n const pageDataScript = document.querySelector("script[data-veryfront-page]");\n if (!pageDataScript) return null;\n try {\n const content = pageDataScript.textContent;\n if (!content) {\n logger2.warn("Page data script has no content");\n return {};\n }\n return JSON.parse(content);\n } catch (error) {\n logger2.error("Failed to parse page data:", error);\n return null;\n }\n}\nfunction descriptorFromDocumentHeadElement(element) {\n const tagName = element.tagName.toLowerCase();\n const record = /* @__PURE__ */ Object.create(null);\n for (const { name, value } of element.attributes) {\n if (!isHeadFrameworkAttribute(name) && name.toLowerCase() !== "nonce") {\n record[name.toLowerCase()] = value;\n }\n }\n const supportsText = tagName === "title" || tagName === "script" || tagName === "style";\n if (supportsText) record[PARSED_ROUTE_HEAD_CONTENT_PROPERTY] = element.textContent ?? "";\n return descriptorFromManagedHeadRecord(tagName, record, {\n ...supportsText && { contentProperty: PARSED_ROUTE_HEAD_CONTENT_PROPERTY }\n });\n}\nfunction payloadDescriptors(root) {\n if (!root || typeof root.querySelectorAll !== "function") return [];\n const descriptors = [];\n for (const element of root.querySelectorAll(`[${HEAD_SSR_PAYLOAD_ATTRIBUTE}]`)) {\n if (element.getAttribute(HEAD_REACT_OWNER_ATTRIBUTE) !== "1") continue;\n const payload = element.getAttribute(HEAD_SSR_PAYLOAD_ATTRIBUTE);\n if (payload) descriptors.push(...deserializeManagedHeadPayload(payload));\n }\n return descriptors;\n}\nfunction snapshotClientRouteHead(targetDocument = document) {\n const descriptors = [];\n let hasStructuredPayload = false;\n const hydrationDataScript = targetDocument.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.managedHeadPayload === "string") {\n descriptors.push(...deserializeManagedHeadPayload(hydrationData.managedHeadPayload));\n hasStructuredPayload = true;\n }\n } catch {\n }\n }\n const committedDescriptors = payloadDescriptors(targetDocument.getElementById("root"));\n descriptors.push(...committedDescriptors);\n const fallbackSelector = [\n ...committedDescriptors.length === 0 ? [`[${HEAD_PROVENANCE_ATTRIBUTE}="true"]`] : [],\n ...!hasStructuredPayload ? [`[${HEAD_SHELL_PROVENANCE_ATTRIBUTE}="true"]`] : []\n ].join(", ");\n if (fallbackSelector && targetDocument.head?.querySelectorAll) {\n for (const element of targetDocument.head.querySelectorAll(fallbackSelector)) {\n const descriptor = descriptorFromDocumentHeadElement(element);\n if (descriptor) descriptors.push(descriptor);\n }\n }\n const aggregated = aggregateManagedHeadDescriptors(descriptors);\n assertManagedHeadDescriptorBudget(aggregated);\n return aggregated.map(managedHeadDescriptorToTransportEntry);\n}\nfunction parsePageDataFromHTML(html) {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const root = doc.getElementById("root");\n if (!root) logger2.warn("[Veryfront] No root element found in HTML");\n const content = root?.innerHTML ?? "";\n const pageDataScript = doc.querySelector("script[data-veryfront-page]");\n let pageData = {};\n if (pageDataScript) {\n try {\n const scriptContent = pageDataScript.textContent;\n if (!scriptContent) {\n logger2.warn("Page data script in HTML has no content");\n } else {\n pageData = JSON.parse(scriptContent);\n }\n } catch (error) {\n logger2.error("Failed to parse page data from HTML:", error);\n }\n }\n let dependencyPinningCacheKey;\n const hydrationDataScript = doc.getElementById("veryfront-hydration-data");\n if (hydrationDataScript?.textContent) {\n try {\n const hydrationData = JSON.parse(hydrationDataScript.textContent);\n if (typeof hydrationData.dependencyPinningCacheKey === "string") {\n dependencyPinningCacheKey = hydrationData.dependencyPinningCacheKey;\n }\n } catch (error) {\n logger2.error("Failed to parse hydration data from HTML:", error);\n }\n }\n const managedHead = snapshotClientRouteHead(doc);\n if (managedHead.some((entry) => entry.tagName === "script") || typeof root?.querySelector === "function" && root.querySelector("script")) {\n pageData = { ...pageData, requiresFullDocumentNavigation: true };\n }\n return { content, pageData, managedHead, dependencyPinningCacheKey };\n}\n\n// src/rendering/client/browser-stubs/config.ts\nvar DEFAULT_PREFETCH_DELAY_MS = 100;\nvar PAGE_TRANSITION_DELAY_MS = 150;\n\n// src/routing/client/navigation-handlers.ts\nvar logger3 = rendererLogger.component("veryfront");\nvar MAX_SCROLL_POSITIONS = 100;\nvar NavigationHandlers = class {\n constructor(prefetchDelay = DEFAULT_PREFETCH_DELAY_MS, prefetchOptions = {}) {\n __publicField(this, "prefetchQueue", /* @__PURE__ */ new Set());\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "scrollPositions", /* @__PURE__ */ new Map());\n __publicField(this, "isPopStateNav", false);\n __publicField(this, "prefetchDelay");\n __publicField(this, "prefetchOptions");\n this.prefetchDelay = prefetchDelay;\n this.prefetchOptions = prefetchOptions;\n }\n createClickHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n const anchor = findAnchorElement(event.target);\n if (!anchor || !isInternalLink(anchor)) return;\n const href = anchor.getAttribute("href");\n if (!href) return;\n event.preventDefault();\n callbacks.onNavigate(href);\n };\n }\n createPopStateHandler(callbacks) {\n return (_event) => {\n this.isPopStateNav = true;\n const { pathname, search, hash } = globalThis.location;\n callbacks.onNavigate(`${pathname}${search}${hash}`);\n };\n }\n createMouseOverHandler(callbacks) {\n return (event) => {\n if (!(event.target instanceof HTMLElement)) return;\n if (event.target.tagName !== "A") return;\n const href = event.target.getAttribute("href");\n if (!href || href.startsWith("http") || href.startsWith("#")) return;\n if (!this.shouldPrefetchOnHover(event.target)) return;\n if (this.prefetchQueue.has(href)) return;\n this.prefetchQueue.add(href);\n const timeoutId = setTimeout(() => {\n callbacks.onPrefetch(href);\n this.prefetchQueue.delete(href);\n this.pendingTimeouts.delete(href);\n }, this.prefetchDelay);\n this.pendingTimeouts.set(href, timeoutId);\n };\n }\n shouldPrefetchOnHover(target) {\n const prefetchAttribute = target.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n if (prefetchAttribute === "true") return true;\n return Boolean(this.prefetchOptions.hover);\n }\n saveScrollPosition(path) {\n try {\n if (this.scrollPositions.size >= MAX_SCROLL_POSITIONS) {\n const oldest = this.scrollPositions.keys().next().value;\n if (oldest) this.scrollPositions.delete(oldest);\n }\n const scrollY = globalThis.scrollY;\n if (typeof scrollY !== "number") {\n logger3.debug("No valid scrollY value available");\n this.scrollPositions.set(path, 0);\n return;\n }\n this.scrollPositions.set(path, scrollY);\n } catch (error) {\n logger3.warn("failed to record scroll position", error);\n }\n }\n getScrollPosition(path) {\n const position = this.scrollPositions.get(path);\n if (position === void 0) {\n logger3.debug(`No scroll position stored for ${path}`);\n return 0;\n }\n return position;\n }\n isPopState() {\n return this.isPopStateNav;\n }\n clearPopStateFlag() {\n this.isPopStateNav = false;\n }\n clear() {\n for (const timeoutId of this.pendingTimeouts.values()) clearTimeout(timeoutId);\n this.pendingTimeouts.clear();\n this.prefetchQueue.clear();\n this.scrollPositions.clear();\n this.isPopStateNav = false;\n }\n};\n\n// src/rendering/client/browser-stubs/error-registry.ts\nfunction createBrowserError(name, fallbackMessage) {\n return {\n create(options = {}) {\n const error = new Error(options.detail ?? fallbackMessage);\n error.name = name;\n Object.assign(error, {\n status: options.status,\n context: options.context\n });\n return error;\n }\n };\n}\nvar NETWORK_ERROR = createBrowserError("NetworkError", "Network request failed");\nvar SECURITY_VIOLATION = createBrowserError("SecurityViolation", "Security violation");\n\n// src/html/html-detection.ts\nfunction isFullHTMLDocument(content) {\n const trimmed = content.trim().toLowerCase();\n return trimmed.startsWith("");\n}\n\n// src/routing/client/page-loader.ts\nvar logger4 = rendererLogger.component("veryfront");\nvar MAX_CACHE_SIZE = 50;\nvar HYDRATION_DATA_ID = "veryfront-hydration-data";\nvar DEPENDENCY_PINNING_RESPONSE_HEADER = "x-veryfront-dependency-pins";\nfunction reloadBrowserDocument(url) {\n if (typeof globalThis.location !== "undefined") {\n globalThis.location.assign(url);\n }\n}\nfunction readDependencyPinningCacheKey(doc) {\n if (!doc) return "off";\n try {\n const hydrationDataElement = doc.getElementById(HYDRATION_DATA_ID);\n if (!hydrationDataElement?.textContent) return "off";\n const hydrationData = JSON.parse(hydrationDataElement.textContent);\n return typeof hydrationData.dependencyPinningCacheKey === "string" && hydrationData.dependencyPinningCacheKey.startsWith("on:") ? hydrationData.dependencyPinningCacheKey : "off";\n } catch (error) {\n logger4.debug("Failed to read dependency snapshot from hydration data:", error);\n return "off";\n }\n}\nvar PageLoader = class {\n constructor(doc = typeof document === "undefined" ? void 0 : document, reloadDocument = reloadBrowserDocument) {\n __publicField(this, "cache", /* @__PURE__ */ new Map());\n __publicField(this, "spaCache", /* @__PURE__ */ new Map());\n __publicField(this, "pendingRequests", /* @__PURE__ */ new Map());\n __publicField(this, "pendingSpaRequests", /* @__PURE__ */ new Map());\n /**\n * A loader belongs to the dependency snapshot of the document that created it.\n * Keeping this immutable also prevents cached or in-flight route data from\n * crossing snapshot boundaries if the hydration element is later replaced.\n */\n __publicField(this, "dependencyPinningCacheKey");\n __publicField(this, "reloadDocument");\n __publicField(this, "snapshotRecoveryStarted", false);\n this.dependencyPinningCacheKey = readDependencyPinningCacheKey(doc);\n this.reloadDocument = reloadDocument;\n }\n evictIfFull(map) {\n if (map.size < MAX_CACHE_SIZE) return;\n const oldest = map.keys().next().value;\n if (oldest) map.delete(oldest);\n }\n getCached(path) {\n return this.cache.get(this.snapshotScopedPath(path));\n }\n isCached(path) {\n return this.cache.has(this.snapshotScopedPath(path));\n }\n setCache(path, data) {\n this.evictIfFull(this.cache);\n this.cache.set(this.snapshotScopedPath(path), data);\n }\n clearCache() {\n this.cache.clear();\n this.spaCache.clear();\n this.pendingRequests.clear();\n this.pendingSpaRequests.clear();\n }\n getSpaCached(path) {\n return this.spaCache.get(this.snapshotScopedPath(path));\n }\n isSpaDataCached(path) {\n return this.spaCache.has(this.snapshotScopedPath(path));\n }\n setSpaCache(path, data) {\n this.evictIfFull(this.spaCache);\n this.spaCache.set(this.snapshotScopedPath(path), data);\n }\n async fetchPageData(path, reloadOnSnapshotFailure = true) {\n try {\n return await this.tryFetchJSON(path) ?? await this.fetchAndParseHTML(path);\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n async tryFetchJSON(path) {\n let response;\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const dataPath = navigationUrl.pathname === "/" ? "/index" : navigationUrl.pathname;\n const endpoint = `/_veryfront/data${dataPath}.json${navigationUrl.search}`;\n response = await fetch(endpoint, {\n headers: this.navigationHeaders("client")\n });\n } catch (error) {\n logger4.debug(`JSON fetch failed for ${path}, falling back to HTML:`, error);\n return null;\n }\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) return null;\n let data;\n try {\n data = await response.json();\n } catch (error) {\n logger4.debug(`JSON response was invalid for ${path}, falling back to HTML:`, error);\n return null;\n }\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "route data"\n );\n if (typeof data.html === "string" && isFullHTMLDocument(data.html)) {\n const parsed = parsePageDataFromHTML(data.html);\n this.assertDependencySnapshot(\n parsed.dependencyPinningCacheKey,\n path,\n "route data HTML body"\n );\n return {\n ...parsed.pageData,\n ...data,\n html: parsed.content,\n managedHead: parsed.managedHead\n };\n }\n return data;\n }\n async fetchAndParseHTML(path) {\n const response = await fetch(path, {\n headers: this.navigationHeaders("client")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch ${path}`,\n status: response.status,\n context: { path }\n });\n }\n this.assertDependencySnapshot(\n response.headers.get(DEPENDENCY_PINNING_RESPONSE_HEADER),\n path,\n "HTML response"\n );\n const html = await response.text();\n const {\n content,\n pageData,\n managedHead,\n dependencyPinningCacheKey\n } = parsePageDataFromHTML(html);\n this.assertDependencySnapshot(\n dependencyPinningCacheKey,\n path,\n "HTML body"\n );\n return { ...pageData, html: content, managedHead };\n }\n loadPage(path) {\n return this.loadPageWithSnapshotRecovery(path, true);\n }\n loadPageWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getCached(path);\n if (cachedData) {\n logger4.debug(`Loading ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingRequests, async () => {\n const data = await this.fetchPageData(path, false);\n this.setCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetch(path) {\n if (this.isCached(path)) return;\n logger4.debug(`Prefetching ${path}`);\n try {\n await this.loadPageWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n async fetchSpaPageData(path, reloadOnSnapshotFailure = true) {\n try {\n const navigationUrl = new URL(path, "http://veryfront.local");\n const normalizedPath = navigationUrl.pathname === "/" ? "index" : navigationUrl.pathname.replace(/^\\//, "");\n const endpoint = `/_veryfront/page-data/${normalizedPath}.json${navigationUrl.search}`;\n logger4.debug(`Fetching SPA page data from ${endpoint}`);\n const response = await fetch(endpoint, {\n headers: this.navigationHeaders("spa")\n });\n if (response.status === 409) {\n this.failDependencySnapshot(\n path,\n `Dependency snapshot is unavailable for SPA page data ${path}`\n );\n }\n if (!response.ok) {\n throw NETWORK_ERROR.create({\n detail: `Failed to fetch SPA page data for ${path}`,\n status: response.status,\n context: { path }\n });\n }\n const data = await response.json();\n this.assertDependencySnapshot(\n data.dependencyPinningCacheKey,\n path,\n "SPA page data"\n );\n return data;\n } catch (error) {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n }\n }\n loadSpaPageData(path) {\n return this.loadSpaPageDataWithSnapshotRecovery(path, true);\n }\n loadSpaPageDataWithSnapshotRecovery(path, reloadOnSnapshotFailure) {\n const cachedData = this.getSpaCached(path);\n if (cachedData) {\n logger4.debug(`Loading SPA data for ${path} from cache`);\n return Promise.resolve(cachedData);\n }\n const pendingKey = this.snapshotScopedPath(path);\n const pending = this.pendingSpaRequests.get(pendingKey);\n if (pending) {\n logger4.debug(`Reusing pending SPA request for ${path}`);\n return this.withSnapshotRecovery(\n pending,\n path,\n reloadOnSnapshotFailure\n );\n }\n logger4.debug(`Creating pending SPA request for ${path}`);\n const request = this.createPendingRequest(pendingKey, this.pendingSpaRequests, async () => {\n const data = await this.fetchSpaPageData(path, false);\n this.setSpaCache(path, data);\n return data;\n });\n return this.withSnapshotRecovery(\n request,\n path,\n reloadOnSnapshotFailure\n );\n }\n async prefetchSpaPageData(path) {\n if (this.isSpaDataCached(path)) return;\n logger4.debug(`Prefetching SPA page data for ${path}`);\n try {\n await this.loadSpaPageDataWithSnapshotRecovery(path, false);\n } catch (error) {\n logger4.warn(\n `[Veryfront] Failed to prefetch SPA data for ${path}`,\n error instanceof Error ? error : new Error(String(error))\n );\n }\n }\n createPendingRequest(path, pendingMap, fetcher) {\n const request = (async () => {\n try {\n return await fetcher();\n } finally {\n pendingMap.delete(path);\n }\n })();\n pendingMap.set(path, request);\n return request;\n }\n snapshotScopedPath(path) {\n return this.dependencyPinningCacheKey.startsWith("on:") ? `${this.dependencyPinningCacheKey}\\0${path}` : path;\n }\n navigationHeaders(type) {\n return {\n "X-Veryfront-Navigation": type,\n ...this.dependencyPinningCacheKey.startsWith("on:") ? {\n [DEPENDENCY_PINNING_RESPONSE_HEADER]: this.dependencyPinningCacheKey\n } : {}\n };\n }\n assertDependencySnapshot(actualCacheKey, path, source) {\n const expectedCacheKey = this.dependencyPinningCacheKey.startsWith("on:") ? this.dependencyPinningCacheKey : void 0;\n const normalizedActualCacheKey = typeof actualCacheKey === "string" ? actualCacheKey : void 0;\n const matches = expectedCacheKey ? normalizedActualCacheKey === expectedCacheKey : normalizedActualCacheKey === void 0 || normalizedActualCacheKey === "off";\n if (matches) return;\n this.failDependencySnapshot(\n path,\n `Dependency snapshot mismatch in ${source} for ${path}`\n );\n }\n failDependencySnapshot(path, detail) {\n throw NETWORK_ERROR.create({\n detail,\n status: 409,\n context: { path }\n });\n }\n withSnapshotRecovery(promise, path, reloadOnSnapshotFailure) {\n return promise.catch((error) => {\n this.recoverSnapshotFailure(error, path, reloadOnSnapshotFailure);\n throw error;\n });\n }\n recoverSnapshotFailure(error, path, reloadOnSnapshotFailure) {\n if (!reloadOnSnapshotFailure || typeof error !== "object" || error === null || error.status !== 409) {\n return;\n }\n if (this.snapshotRecoveryStarted) return;\n this.snapshotRecoveryStarted = true;\n try {\n this.reloadDocument(path);\n } catch (reloadError) {\n this.snapshotRecoveryStarted = false;\n logger4.warn(\n `[Veryfront] Failed to reload after dependency snapshot conflict for ${path}`,\n reloadError instanceof Error ? reloadError : new Error(String(reloadError))\n );\n }\n }\n};\n\n// src/utils/logger/core.ts\nvar ANSI = {\n reset: "\\x1B[0m",\n dim: "\\x1B[2m",\n gray: "\\x1B[90m",\n red: "\\x1B[31m",\n green: "\\x1B[32m",\n yellow: "\\x1B[33m",\n blue: "\\x1B[34m",\n magenta: "\\x1B[35m",\n cyan: "\\x1B[36m"\n};\nvar LEVEL_COLORS = {\n debug: ANSI.gray,\n info: ANSI.green,\n warn: ANSI.yellow,\n error: ANSI.red\n};\n\n// src/utils/logger/redact.ts\nvar REDACTED = "[REDACTED]";\nvar apply = Reflect.apply;\nvar regExpExec = RegExp.prototype.exec;\nvar regExpReplace = RegExp.prototype[Symbol.replace];\nvar stringCharCodeAt = String.prototype.charCodeAt;\nvar stringSlice = String.prototype.slice;\nvar stringToLowerCase = String.prototype.toLowerCase;\nvar NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]/g;\nfunction normalizeToAlphanumeric(s) {\n const lowercase = apply(stringToLowerCase, s, []);\n return apply(regExpReplace, NON_ALPHANUMERIC_PATTERN, [lowercase, ""]);\n}\nfunction sliceString(value, start, end) {\n return end === void 0 ? apply(stringSlice, value, [start]) : apply(stringSlice, value, [start, end]);\n}\nvar SENSITIVE_KEY_PATTERNS = [\n "password",\n "passwd",\n "pwd",\n "passphrase",\n "secret",\n "clientsecret",\n "token",\n "apikey",\n "accesskey",\n "privatekey",\n "credential",\n "authorization",\n "cookie",\n "bearer",\n "jwt",\n "connectionstring",\n "signature",\n "sessionid",\n "sid",\n "otp",\n "mfa",\n "pin",\n "salt",\n "xsrf",\n "csrf"\n];\nvar SENSITIVE_KEY_CACHE_MAX_SIZE = 512;\nvar SENSITIVE_KEY_CACHE_MAX_KEY_LENGTH = 128;\nvar sensitiveKeyCache = /* @__PURE__ */ new Map();\nfunction isSensitiveKey(key) {\n const cacheable = key.length <= SENSITIVE_KEY_CACHE_MAX_KEY_LENGTH;\n if (cacheable) {\n const cached = sensitiveKeyCache.get(key);\n if (cached !== void 0) return cached;\n }\n const normalized = normalizeToAlphanumeric(key);\n const sensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern));\n if (cacheable) {\n if (sensitiveKeyCache.size >= SENSITIVE_KEY_CACHE_MAX_SIZE) {\n const oldestKey = sensitiveKeyCache.keys().next().value;\n if (oldestKey !== void 0) sensitiveKeyCache.delete(oldestKey);\n }\n sensitiveKeyCache.set(key, sensitive);\n }\n return sensitive;\n}\nvar SENSITIVE_URL_PARAMS = [\n "access_token",\n "accesstoken",\n "refresh_token",\n "api_key",\n "apikey",\n "code",\n "token",\n "secret",\n "client_secret",\n "password",\n "passwd",\n "pwd",\n "state",\n "sig",\n "signature",\n "auth",\n "x-amz-credential",\n "x-amz-signature",\n "x-amz-security-token",\n "x-goog-credential",\n "x-goog-signature"\n];\nvar NORMALIZED_SENSITIVE_URL_PARAMS = new Set(SENSITIVE_URL_PARAMS.map(normalizeToAlphanumeric));\nvar URL_USERINFO_RE = /(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi;\nvar HORIZONTAL_WHITESPACE_URL_USERINFO_RE = /(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi;\nvar MAX_URL_PARAMETER_DECODE_PASSES = 3;\nfunction isHorizontalAssignmentBoundary(character) {\n return character === " " || character === "\t" || character === "," || character === ";" || character === "&" || character === "?" || character === "#";\n}\nfunction isAsciiLetter(character) {\n if (!character) return false;\n const code = character.charCodeAt(0);\n return code >= 65 && code <= 90 || code >= 97 && code <= 122;\n}\nfunction isAssignmentKeyStartCharacter(character) {\n return isAsciiLetter(character) || character === "_" || character === "$";\n}\nfunction isAssignmentKeyCharacter(character) {\n if (!character) return false;\n const code = character.charCodeAt(0);\n return isAssignmentKeyStartCharacter(character) || code >= 48 && code <= 57 || character === "." || character === "-";\n}\nfunction assignmentStartsAt(input, start) {\n let index = start;\n const keyQuote = input[index] === `"` || input[index] === "\'" ? input[index++] : "";\n if (!isAssignmentKeyStartCharacter(input[index])) return false;\n index++;\n while (isAssignmentKeyCharacter(input[index])) index++;\n if (keyQuote) {\n if (input[index] !== keyQuote) return false;\n index++;\n }\n while (input[index] === " " || input[index] === "\t") index++;\n return input[index] === ":" || input[index] === "=";\n}\nfunction isAssignmentBoundaryCharacter(character) {\n return character === "\\r" || character === "\\n" || character === "}" || character === "]" || isHorizontalAssignmentBoundary(character);\n}\nfunction skipAssignmentBoundaryCharacters(input, start) {\n let index = start;\n while (index < input.length && isAssignmentBoundaryCharacter(input[index])) {\n index++;\n }\n return index;\n}\nfunction assignmentValueEndsAt(input, start) {\n const boundaryEnd = skipAssignmentBoundaryCharacters(input, start);\n return boundaryEnd >= input.length || assignmentStartsAt(input, boundaryEnd);\n}\nfunction redactAssignmentValue(input, start) {\n let scanStart = start;\n let preserveValueQuote = true;\n if (input.startsWith(REDACTED, start)) {\n const markerEnd = start + REDACTED.length;\n if (assignmentValueEndsAt(input, markerEnd)) {\n return {\n end: markerEnd,\n replacement: REDACTED\n };\n }\n scanStart = markerEnd;\n preserveValueQuote = false;\n }\n const wrapperQuote = preserveValueQuote && (input[scanStart] === `"` || input[scanStart] === "\'" || input[scanStart] === "`") ? input[scanStart] : "";\n let wrapperQuoteClosed = false;\n const replacement = () => wrapperQuote ? `${wrapperQuote}${REDACTED}${wrapperQuoteClosed ? wrapperQuote : ""}` : REDACTED;\n const expectedClosings = [];\n let quote = "";\n let quoteStart = -1;\n for (let index = scanStart; index < input.length; ) {\n const character = input[index];\n if (quote) {\n if (character === "\\\\") {\n index += 2;\n continue;\n }\n if (character === quote) {\n if (quoteStart === scanStart && expectedClosings.length === 0) {\n wrapperQuoteClosed = true;\n }\n quote = "";\n quoteStart = -1;\n }\n index++;\n continue;\n }\n if (character === `"` || character === "\'" || character === "`") {\n quote = character;\n quoteStart = index;\n index++;\n continue;\n }\n if (character === "{" || character === "[") {\n expectedClosings.push(character === "{" ? "}" : "]");\n index++;\n continue;\n }\n if (expectedClosings.length > 0 && (character === "}" || character === "]")) {\n if (expectedClosings.at(-1) !== character) {\n return { end: input.length, replacement: replacement() };\n }\n expectedClosings.pop();\n index++;\n if (expectedClosings.length === 0 && assignmentValueEndsAt(input, index)) {\n return { end: index, replacement: replacement() };\n }\n continue;\n }\n if (expectedClosings.length > 0 || !isAssignmentBoundaryCharacter(character)) {\n index++;\n continue;\n }\n const boundaryStart = index;\n index = skipAssignmentBoundaryCharacters(input, index);\n if (index >= input.length || assignmentStartsAt(input, index)) {\n return { end: boundaryStart, replacement: replacement() };\n }\n }\n return { end: input.length, replacement: replacement() };\n}\nfunction redactCredentialAssignments(input, prefixPattern, keyGroup, urlParameterBoundaryGroup) {\n let cursor = 0;\n let result = "";\n for (let match = apply(regExpExec, prefixPattern, [input]); match; match = apply(regExpExec, prefixPattern, [input])) {\n const key = match[keyGroup];\n if (!isSensitiveKey(key)) continue;\n const valueStart = prefixPattern.lastIndex;\n const boundary = urlParameterBoundaryGroup === void 0 ? void 0 : match[urlParameterBoundaryGroup];\n const markerEnd = valueStart + REDACTED.length;\n if ((boundary === "?" || boundary === "&" || boundary === ";") && input.startsWith(REDACTED, valueStart) && input[markerEnd] === "#") {\n continue;\n }\n const redactedValue = redactAssignmentValue(input, valueStart);\n result += sliceString(input, cursor, match.index);\n result += match[0];\n result += redactedValue.replacement;\n cursor = redactedValue.end;\n prefixPattern.lastIndex = redactedValue.end;\n }\n return cursor === 0 ? input : result + sliceString(input, cursor);\n}\nfunction isStandaloneUrlAuthorityBeforeWhitespace(scheme, user, password) {\n const whitespaceIndex = password.search(/[ \\t]/);\n if (whitespaceIndex < 0) return false;\n const authority = `${user}:${sliceString(password, 0, whitespaceIndex)}`;\n const candidate = scheme === "//" ? `https://${authority}` : `${scheme}${authority}`;\n try {\n const url = new URL(candidate);\n return url.username.length === 0 && url.password.length === 0;\n } catch {\n return false;\n }\n}\nfunction decodeUrlParameterName(value) {\n let decoded = value;\n for (let pass = 0; pass < MAX_URL_PARAMETER_DECODE_PASSES; pass++) {\n let next;\n try {\n next = decodeURIComponent(decoded);\n } catch {\n break;\n }\n if (next === decoded) break;\n decoded = next;\n }\n return decoded;\n}\nfunction sanitizeUrlCredentials(input) {\n if (typeof input !== "string" || input.length === 0) return input;\n let out = apply(regExpReplace, URL_USERINFO_RE, [\n input,\n (_match, scheme, userinfo) => {\n const colon = userinfo.indexOf(":");\n if (colon === -1) {\n return `${scheme}${REDACTED}@`;\n }\n const user = sliceString(userinfo, 0, colon);\n return `${scheme}${user}:${REDACTED}@`;\n }\n ]);\n out = apply(regExpReplace, HORIZONTAL_WHITESPACE_URL_USERINFO_RE, [\n out,\n (match, scheme, user, password) => {\n if (isStandaloneUrlAuthorityBeforeWhitespace(scheme, user, password)) {\n return match;\n }\n return `${scheme}${user}:${REDACTED}@`;\n }\n ]);\n out = apply(regExpReplace, /([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi, [\n out,\n (match, sep, key, _val) => {\n const decodedKey = decodeUrlParameterName(key);\n const sensitive = NORMALIZED_SENSITIVE_URL_PARAMS.has(normalizeToAlphanumeric(decodedKey)) || isSensitiveKey(decodedKey);\n return sensitive ? `${sep}${key}=${REDACTED}` : match;\n }\n ]);\n out = apply(regExpReplace, /(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi, [\n out,\n (_match, boundary, prefix) => `${boundary}${prefix}${REDACTED}`\n ]);\n out = apply(regExpReplace, /\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi, [\n out,\n (_match, prefix) => `${prefix}${REDACTED}`\n ]);\n out = apply(\n regExpReplace,\n /\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,\n [\n out,\n (_match, scheme, whitespace) => `${scheme}${whitespace}${REDACTED}`\n ]\n );\n out = redactCredentialAssignments(\n out,\n /(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,\n 2\n );\n out = redactCredentialAssignments(\n out,\n /(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,\n 2,\n 1\n );\n return out;\n}\n\n// src/errors/diagnostic-policy.ts\nvar ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS = 2048;\nvar ERROR_OUTPUT_MAX_LENGTH_CHARS = 64 * 1024;\nvar ERROR_DOCS_SLUG_MAX_LENGTH_CHARS = 256;\nvar ERROR_DOCS_BASE_URL = "https://veryfront.com/docs/errors/";\nvar TRUNCATION_MARKER = "...[truncated]";\nvar UNKNOWN_ERROR_SLUG = "unknown-error";\nfunction truncateDiagnosticText(value, maxLength) {\n if (value.length <= maxLength) return value;\n const prefixLength = Math.max(0, maxLength - TRUNCATION_MARKER.length);\n const prefix = takeSafePrefix(value, prefixLength);\n return `${prefix}${TRUNCATION_MARKER}`;\n}\nfunction takeSafePrefix(value, length) {\n let prefix = value.slice(0, length);\n const finalCodeUnit = prefix.charCodeAt(prefix.length - 1);\n if (finalCodeUnit >= 55296 && finalCodeUnit <= 56319) {\n prefix = prefix.slice(0, -1);\n }\n return prefix;\n}\nfunction replaceLoneSurrogates(value) {\n let result = "";\n for (let index = 0; index < value.length; index++) {\n const codeUnit = value.charCodeAt(index);\n if (codeUnit >= 55296 && codeUnit <= 56319) {\n const nextCodeUnit = value.charCodeAt(index + 1);\n if (nextCodeUnit >= 56320 && nextCodeUnit <= 57343) {\n result += value.slice(index, index + 2);\n index++;\n } else {\n result += "\\uFFFD";\n }\n continue;\n }\n result += codeUnit >= 56320 && codeUnit <= 57343 ? "\\uFFFD" : value.charAt(index);\n }\n return result;\n}\nfunction sanitizeBoundedDiagnosticText(value) {\n if (typeof value !== "string") return REDACTED;\n return truncateDiagnosticText(\n sanitizeUrlCredentials(value),\n ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS\n );\n}\nfunction sanitizeBoundedErrorSlug(slug) {\n const sanitized = typeof slug === "string" ? sanitizeUrlCredentials(slug) : UNKNOWN_ERROR_SLUG;\n const bounded = truncateDiagnosticText(\n sanitized || UNKNOWN_ERROR_SLUG,\n ERROR_DOCS_SLUG_MAX_LENGTH_CHARS\n );\n const normalized = replaceLoneSurrogates(bounded);\n return normalized === "." || normalized === ".." ? UNKNOWN_ERROR_SLUG : normalized;\n}\nfunction buildErrorDocsUrl(slug) {\n const segment = encodeURIComponent(sanitizeBoundedErrorSlug(slug));\n return `${ERROR_DOCS_BASE_URL}${segment}`;\n}\n\n// src/errors/types.ts\nvar freeze = Object.freeze;\nvar getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors;\nvar numberIsFinite = Number.isFinite;\nvar VERYFRONT_ERROR_INSTANCES = /* @__PURE__ */ new WeakSet();\nvar ERROR_CATEGORIES = /* @__PURE__ */ new Set([\n "CONFIG",\n "BUILD",\n "RUNTIME",\n "ROUTE",\n "MODULE",\n "SERVER",\n "BOUNDARY",\n "DEV",\n "DEPLOY",\n "AGENT",\n "GENERAL"\n]);\nfunction defineError(definition) {\n const snapshot = { ...definition };\n const registered = {\n ...snapshot,\n create(options) {\n const message = options?.message;\n const detail = options?.detail;\n const cause = options?.cause;\n const instance = options?.instance;\n const context = options?.context;\n const status = options?.status ?? snapshot.status;\n return new VeryfrontError(message || detail || snapshot.title, {\n slug: snapshot.slug,\n category: snapshot.category,\n status,\n title: snapshot.title,\n suggestion: snapshot.suggestion,\n exitCode: snapshot.exitCode,\n detail,\n cause,\n instance,\n context\n });\n }\n };\n return freeze(registered);\n}\nvar VeryfrontError = class extends Error {\n constructor(message, options) {\n super(message);\n __publicField(this, "slug");\n __publicField(this, "category");\n __publicField(this, "status");\n __publicField(this, "title");\n __publicField(this, "suggestion");\n /** Process exit code for the CLI boundary. */\n __publicField(this, "exitCode");\n __publicField(this, "detail");\n __publicField(this, "cause");\n __publicField(this, "instance");\n __publicField(this, "context");\n VERYFRONT_ERROR_INSTANCES.add(this);\n this.name = "VeryfrontError";\n this.slug = options.slug;\n this.category = options.category;\n this.status = options.status;\n this.title = options.title;\n this.suggestion = options.suggestion;\n this.exitCode = options.exitCode;\n this.detail = options.detail;\n this.cause = options.cause;\n this.instance = options.instance;\n this.context = options.context;\n }\n /**\n * Convert to RFC 9457 Problem Details format\n */\n toRFC9457() {\n const snapshot = snapshotVeryfrontError(this);\n if (!snapshot) {\n return {\n type: buildErrorDocsUrl("unknown-error"),\n title: "Unknown/unclassified error",\n status: 500,\n category: "GENERAL"\n };\n }\n return {\n type: buildErrorDocsUrl(snapshot.slug),\n title: sanitizeBoundedDiagnosticText(snapshot.title),\n status: snapshot.status,\n detail: snapshot.detail === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.detail),\n instance: snapshot.instance === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.instance),\n category: snapshot.category,\n suggestion: snapshot.suggestion === void 0 ? void 0 : sanitizeBoundedDiagnosticText(snapshot.suggestion),\n cause: typeof snapshot.cause === "string" ? sanitizeBoundedDiagnosticText(snapshot.cause) : void 0\n };\n }\n /**\n * Get documentation URL for this error\n */\n getDocsUrl() {\n const snapshot = snapshotVeryfrontError(this);\n return buildErrorDocsUrl(snapshot?.slug ?? "unknown-error");\n }\n};\nfunction isVeryfrontErrorInstance(error) {\n return typeof error === "object" && error !== null && VERYFRONT_ERROR_INSTANCES.has(error);\n}\nfunction snapshotVeryfrontError(error) {\n if (!isVeryfrontErrorInstance(error)) return null;\n return snapshotKnownVeryfrontError(error);\n}\nfunction snapshotKnownVeryfrontError(error) {\n try {\n if (!isVeryfrontErrorInstance(error)) return null;\n const descriptors = getOwnPropertyDescriptors(error);\n const dataValue = (key) => {\n const descriptor = descriptors[key];\n return descriptor && "value" in descriptor ? descriptor.value : void 0;\n };\n const slug = dataValue("slug");\n const category = dataValue("category");\n const status = dataValue("status");\n const title = dataValue("title");\n const message = dataValue("message");\n const suggestion = dataValue("suggestion");\n const exitCode = dataValue("exitCode");\n const detail = dataValue("detail");\n const cause = dataValue("cause");\n const instance = dataValue("instance");\n const context = dataValue("context");\n const stack = dataValue("stack");\n if (typeof slug !== "string" || !ERROR_CATEGORIES.has(category) || typeof status !== "number" || !numberIsFinite(status) || typeof title !== "string" || typeof message !== "string" || suggestion !== void 0 && typeof suggestion !== "string" || exitCode !== void 0 && (typeof exitCode !== "number" || !numberIsFinite(exitCode)) || detail !== void 0 && typeof detail !== "string" || instance !== void 0 && typeof instance !== "string" || stack !== void 0 && typeof stack !== "string") {\n return null;\n }\n return {\n slug,\n category,\n status,\n title,\n message,\n suggestion,\n exitCode,\n detail,\n cause,\n instance,\n context,\n stack\n };\n } catch {\n return null;\n }\n}\n\n// src/errors/error-registry/general.ts\nvar UNKNOWN_ERROR = defineError({\n slug: "unknown-error",\n category: "GENERAL",\n status: 500,\n title: "Unknown/unclassified error",\n suggestion: "Check logs for more details"\n});\nvar AUTHENTICATION_REQUIRED = defineError({\n slug: "authentication-required",\n category: "GENERAL",\n status: 401,\n title: "Authentication required",\n suggestion: "Set VERYFRONT_API_TOKEN or run \'veryfront login\'"\n});\nvar PERMISSION_DENIED = defineError({\n slug: "permission-denied",\n category: "GENERAL",\n status: 403,\n title: "File/resource permission denied",\n suggestion: "Check file permissions and access rights"\n});\nvar FILE_NOT_FOUND = defineError({\n slug: "file-not-found",\n category: "GENERAL",\n status: 404,\n title: "File not found",\n suggestion: "Verify the file path exists"\n});\nvar RESOURCE_NOT_FOUND = defineError({\n slug: "resource-not-found",\n category: "GENERAL",\n status: 404,\n title: "Requested resource not found",\n suggestion: "Verify the referenced resource ID or name exists"\n});\nvar INVALID_ARGUMENT = defineError({\n slug: "invalid-argument",\n category: "GENERAL",\n status: 400,\n title: "Invalid function argument",\n suggestion: "Check argument types and values",\n exitCode: 2\n});\nvar TIMEOUT_ERROR = defineError({\n slug: "timeout-error",\n category: "GENERAL",\n status: 408,\n title: "Operation timed out",\n suggestion: "Increase timeout or optimize the operation"\n});\nvar INITIALIZATION_ERROR = defineError({\n slug: "initialization-error",\n category: "GENERAL",\n status: 500,\n title: "Initialization failed",\n suggestion: "Check initialization requirements and dependencies"\n});\nvar NOT_SUPPORTED = defineError({\n slug: "not-supported",\n category: "GENERAL",\n status: 501,\n title: "Feature not supported",\n suggestion: "Check documentation for supported features"\n});\nvar SECURITY_VIOLATION2 = defineError({\n slug: "security-violation",\n category: "GENERAL",\n status: 403,\n title: "Security violation detected",\n suggestion: "Check for path traversal or unauthorized access attempts"\n});\nvar INPUT_VALIDATION_FAILED = defineError({\n slug: "input-validation-failed",\n category: "GENERAL",\n status: 400,\n title: "Input validation failed",\n suggestion: "Check request input against validation rules"\n});\nvar PROJECT_SOURCE_EMPTY = defineError({\n slug: "project-source-empty",\n category: "GENERAL",\n status: 400,\n title: "Project source is empty",\n suggestion: "Add project files or run \'veryfront init\'"\n});\n\n// src/html/html-escape.ts\nvar MAX_ATTRIBUTE_VALUE_BYTES = 64 * 1024;\nvar MAX_TOTAL_ATTRIBUTE_BYTES = 1024 * 1024;\nvar textEncoder = new TextEncoder();\n\n// src/security/client/html-sanitizer.ts\nvar SUSPICIOUS_PATTERN_SPECS = [\n { source: String.raw`]*>[\\s\\S]*?<\\/script>`, flags: "gi", name: "inline script" },\n { source: String.raw`javascript:`, flags: "gi", name: "javascript: URL" },\n { source: String.raw`\\bon\\w+\\s*=`, flags: "gi", name: "event handler attribute" },\n { source: String.raw`data:\\s*text\\/html`, flags: "gi", name: "data: HTML URL" }\n];\nfunction createSuspiciousPatterns() {\n return SUSPICIOUS_PATTERN_SPECS.map(({ source, flags, name }) => ({\n pattern: new RegExp(source, flags),\n name\n }));\n}\nfunction isDevMode() {\n const g = globalThis;\n return g.__VERYFRONT_DEV__ === true || g.Deno?.env?.get?.("VERYFRONT_ENV") === "development";\n}\nfunction validateTrustedHtml(html, options = {}) {\n const { allowInlineScripts = false, strict = false, warn = true } = options;\n for (const { pattern, name } of createSuspiciousPatterns()) {\n if (allowInlineScripts && name === "inline script") continue;\n pattern.lastIndex = 0;\n if (!pattern.test(html)) continue;\n if (warn) console.warn(`[Security] Suspicious ${name} detected in server HTML`);\n if (strict || !isDevMode()) {\n throw SECURITY_VIOLATION.create({ detail: `Potentially unsafe HTML: ${name} detected` });\n }\n }\n return html;\n}\n\n// src/routing/client/page-transition.ts\nvar logger5 = rendererLogger.component("veryfront");\nvar PageTransition = class {\n constructor(setupViewportPrefetch) {\n __publicField(this, "setupViewportPrefetch", setupViewportPrefetch);\n __publicField(this, "pendingTransitionTimeout");\n __publicField(this, "pendingRoot");\n }\n destroy() {\n this.cancelPendingTransition();\n }\n cancelPendingTransition() {\n if (this.pendingTransitionTimeout !== void 0) {\n clearTimeout(this.pendingTransitionTimeout);\n this.pendingTransitionTimeout = void 0;\n }\n if (this.pendingRoot) {\n this.pendingRoot.style.opacity = "1";\n this.pendingRoot = void 0;\n }\n }\n updatePage(data, isPopState, scrollY) {\n this.cancelPendingTransition();\n if (data.requiresFullDocumentNavigation || data.managedHead?.some((entry) => entry.tagName === "script") || typeof data.html === "string" && / {\n this.pendingTransitionTimeout = void 0;\n this.pendingRoot = void 0;\n try {\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = trustedHtml;\n applyHeadDirectives(rootElement);\n applyPreparedClientRouteHeadDescriptors(preparedHead, rootElement.ownerDocument);\n this.updateDocumentMetadata(rootElement.ownerDocument, data, retainedTitle);\n this.setupViewportPrefetch(rootElement);\n manageFocus(rootElement);\n this.handleScroll(isPopState, scrollY);\n } catch (error) {\n logger5.error("Route transition commit failed; reloading the document", error);\n globalThis.location?.reload();\n } finally {\n rootElement.style.opacity = "1";\n }\n }, PAGE_TRANSITION_DELAY_MS);\n }\n handleScroll(isPopState, scrollY) {\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger5.warn("scroll handling failed", error);\n }\n }\n showError(error) {\n const rootElement = document.getElementById("root");\n if (!rootElement) return;\n const errorDiv = document.createElement("div");\n errorDiv.className = "veryfront-error-page";\n const heading = document.createElement("h1");\n heading.textContent = "Oops! Something went wrong";\n const message = document.createElement("p");\n message.textContent = error.message;\n const button = document.createElement("button");\n button.type = "button";\n button.textContent = "Reload Page";\n button.onclick = () => globalThis.location.reload();\n errorDiv.append(heading, message, button);\n retireClientHeadOwnership(rootElement.ownerDocument);\n rootElement.innerHTML = "";\n rootElement.appendChild(errorDiv);\n }\n setLoadingState(loading) {\n const indicator = document.getElementById("veryfront-loading");\n if (indicator) indicator.style.display = loading ? "block" : "none";\n document.body.classList.toggle("veryfront-loading", loading);\n }\n};\n\n// src/routing/client/viewport-prefetch.ts\nvar logger6 = rendererLogger.component("veryfront");\nvar ViewportPrefetch = class {\n constructor(prefetchCallback, prefetchOptions = {}) {\n __publicField(this, "observer", null);\n __publicField(this, "prefetchCallback");\n __publicField(this, "prefetchOptions");\n this.prefetchCallback = prefetchCallback;\n this.prefetchOptions = prefetchOptions;\n }\n setup(root) {\n try {\n if (!("IntersectionObserver" in globalThis)) return;\n this.observer?.disconnect();\n this.createObserver();\n this.observeLinks(root);\n } catch (error) {\n logger6.debug("setupViewportPrefetch failed", error);\n }\n }\n createObserver() {\n this.observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!(entry.target instanceof HTMLAnchorElement)) continue;\n const href = entry.target.getAttribute("href");\n if (href) this.prefetchCallback(href);\n this.observer?.unobserve(entry.target);\n }\n },\n { rootMargin: "200px" }\n );\n }\n observeLinks(root) {\n const anchors = root.querySelectorAll(\'a[href]:not([target="_blank"])\');\n const isViewportEnabled = Boolean(this.prefetchOptions.viewport);\n for (const anchor of anchors) {\n if (!this.shouldObserveAnchor(anchor, isViewportEnabled)) continue;\n this.observer?.observe(anchor);\n }\n }\n shouldObserveAnchor(anchor, isViewportEnabled) {\n const href = anchor.getAttribute("href");\n if (!href) return false;\n if (href.startsWith("http") || href.startsWith("#")) return false;\n if (anchor.getAttribute("download")) return false;\n const prefetchAttribute = anchor.getAttribute("data-prefetch");\n if (prefetchAttribute === "false") return false;\n return prefetchAttribute === "viewport" || isViewportEnabled;\n }\n disconnect() {\n if (!this.observer) return;\n try {\n this.observer.disconnect();\n } catch (error) {\n logger6.warn("prefetchObserver.disconnect failed", error);\n } finally {\n this.observer = null;\n }\n }\n};\n\n// src/rendering/client/router.ts\nvar logger7 = rendererLogger.component("veryfront");\nfunction toHistoryMode(options) {\n if (typeof options === "boolean") return options ? "push" : "none";\n return options?.history ?? "push";\n}\nvar VeryfrontRouter = class {\n constructor(options = {}) {\n __publicField(this, "baseUrl");\n __publicField(this, "currentPath");\n __publicField(this, "root", null);\n __publicField(this, "options");\n __publicField(this, "spaMode");\n __publicField(this, "spaNavigationHandler", null);\n __publicField(this, "navigationSequence", 0);\n __publicField(this, "pageLoader");\n __publicField(this, "navigationHandlers");\n __publicField(this, "pageTransition");\n __publicField(this, "viewportPrefetch");\n __publicField(this, "handleClick");\n __publicField(this, "handlePopState");\n __publicField(this, "handleMouseOver");\n const globalOptions = this.loadGlobalOptions();\n this.options = { ...globalOptions, ...options };\n this.baseUrl = this.options.baseUrl || globalThis.location.origin;\n this.currentPath = `${globalThis.location.pathname}${globalThis.location.search}${globalThis.location.hash}`;\n this.spaMode = this.options.spaMode ?? globalThis.__VERYFRONT_SPA_MODE__ ?? false;\n this.pageLoader = new PageLoader();\n this.navigationHandlers = new NavigationHandlers(\n this.options.prefetchDelay,\n this.options.prefetch\n );\n this.pageTransition = new PageTransition((root) => this.viewportPrefetch.setup(root));\n this.viewportPrefetch = new ViewportPrefetch(\n (path) => this.prefetch(path),\n this.options.prefetch\n );\n this.handleClick = this.navigationHandlers.createClickHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handlePopState = this.navigationHandlers.createPopStateHandler({\n // The browser already updated the URL for a popstate, so don\'t touch history.\n onNavigate: (url) => this.navigate(url, { history: "none" }),\n onPrefetch: (url) => this.prefetch(url)\n });\n this.handleMouseOver = this.navigationHandlers.createMouseOverHandler({\n onNavigate: (url) => this.navigate(url),\n onPrefetch: (url) => this.prefetch(url)\n });\n getNavigationStore().setNavigator((href, options2) => this.navigate(href, options2));\n }\n registerNavigationHandler(handler) {\n logger7.debug("Registering SPA navigation handler");\n this.spaNavigationHandler = handler;\n this.spaMode = true;\n }\n /**\n * Notify React (and any other) subscribers that a navigation completed —\n * after full page loads, soft same-route changes, and popstate. Delegates to\n * the shared navigation store, the single subscription surface both bundles\n * share.\n */\n notify() {\n getNavigationStore().notify();\n }\n pathnameOf(url) {\n try {\n return new URL(url, this.baseUrl).pathname;\n } catch {\n return url.split("?")[0]?.split("#")[0] || this.currentPath;\n }\n }\n loadGlobalOptions() {\n try {\n const options = globalThis.__VERYFRONT_ROUTER_OPTS__;\n if (!options) {\n logger7.debug("No global options configured");\n return {};\n }\n return options;\n } catch (error) {\n logger7.error("Failed to read global options:", error);\n return {};\n }\n }\n init() {\n logger7.debug("Initializing client-side router");\n const rootElement = document.getElementById("root");\n if (!rootElement) {\n logger7.error("Root element not found");\n return;\n }\n const ReactDOMToUse = globalThis.ReactDOM ?? ReactDOM;\n this.root = ReactDOMToUse.createRoot(rootElement);\n document.addEventListener("click", this.handleClick);\n globalThis.addEventListener("popstate", this.handlePopState);\n document.addEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.setup(document);\n this.cacheCurrentPage();\n }\n cacheCurrentPage() {\n const pageData = extractPageDataFromScript();\n if (pageData) {\n const managedHead = snapshotClientRouteHead(document);\n this.pageLoader.setCache(this.currentPath, {\n ...pageData,\n managedHead,\n ...managedHead.some((entry) => entry.tagName === "script") || document.getElementById("root")?.querySelector("script") ? { requiresFullDocumentNavigation: true } : {}\n });\n }\n }\n /**\n * Navigate to a URL. `options` selects the history behaviour: `{ history:\n * "push" }` (default), `"replace"`, or `"none"` (the URL already reflects the\n * target, as after popstate). A boolean is accepted for backward\n * compatibility — `true` pushes, `false` maps to `"none"`.\n */\n async navigate(url, options) {\n logger7.debug(`Navigating to ${url} (SPA mode: ${this.spaMode})`);\n const navigationId = ++this.navigationSequence;\n this.pageTransition.cancelPendingTransition();\n this.pageTransition.setLoadingState(false);\n const history = toHistoryMode(options);\n const sameRoute = this.pathnameOf(url) === this.pathnameOf(this.currentPath);\n this.navigationHandlers.saveScrollPosition(this.currentPath);\n this.options.onStart?.(url);\n if (history === "replace") globalThis.history.replaceState({}, "", url);\n else if (history === "push") globalThis.history.pushState({}, "", url);\n if (sameRoute && !this.shouldRevalidate(url, sameRoute)) {\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = url;\n this.notify();\n this.options.onComplete?.(url);\n this.options.onNavigate?.(url);\n return;\n }\n if (this.spaMode && this.spaNavigationHandler) {\n await this.loadSpaPage(url, navigationId);\n } else {\n if (await this.loadPage(url, true, navigationId)) return;\n }\n if (!this.isCurrentNavigation(navigationId)) return;\n this.notify();\n this.options.onNavigate?.(url);\n }\n isCurrentNavigation(navigationId) {\n return navigationId === this.navigationSequence;\n }\n /**\n * Whether a navigation should refetch page data. A route change always does;\n * a same-route (query/hash-only) change consults `options.shouldRevalidate`,\n * defaulting to `true` so server data is never shown stale.\n */\n shouldRevalidate(nextUrl, sameRoute) {\n const policy = this.options.shouldRevalidate;\n if (!policy) return true;\n return policy({ currentHref: this.currentPath, nextHref: nextUrl, sameRoute });\n }\n async loadSpaPage(path, navigationId) {\n logger7.debug(`Loading SPA page: ${path}`);\n try {\n const spaData = await this.pageLoader.loadSpaPageData(path);\n if (!this.isCurrentNavigation(navigationId)) return;\n await this.spaNavigationHandler?.(spaData);\n if (!this.isCurrentNavigation(navigationId)) return;\n this.currentPath = path;\n this.handleScrollAfterNavigation();\n this.options.onComplete?.(path);\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load SPA page ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n }\n }\n handleScrollAfterNavigation() {\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(this.currentPath);\n try {\n globalThis.scrollTo(0, isPopState ? scrollY : 0);\n } catch (error) {\n logger7.warn("scroll handling failed", error);\n }\n this.navigationHandlers.clearPopStateFlag();\n }\n /** Returns true when navigation was handed to the browser document loader. */\n async loadPage(path, updateUI = true, navigationId) {\n if (this.pageLoader.isCached(path)) {\n logger7.debug(`Loading ${path} from cache`);\n const data = this.pageLoader.getCached(path);\n if (data) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.pageTransition.setLoadingState(false);\n this.options.onComplete?.(path);\n return false;\n }\n logger7.warn(`Cache entry for ${path} was unexpectedly null, fetching fresh data`);\n }\n this.pageTransition.setLoadingState(true);\n try {\n const data = await this.pageLoader.loadPage(path);\n if (!this.isCurrentNavigation(navigationId)) return false;\n if (updateUI && data.requiresFullDocumentNavigation) {\n globalThis.location.assign(path);\n return true;\n }\n if (updateUI) this.updatePage(data, path);\n this.currentPath = path;\n this.options.onComplete?.(path);\n return false;\n } catch (error) {\n if (!this.isCurrentNavigation(navigationId)) return false;\n const normalizedError = error instanceof Error ? error : new Error(String(error));\n logger7.error(`Failed to load ${path}`, normalizedError);\n this.options.onError?.(normalizedError);\n this.pageTransition.showError(normalizedError);\n return false;\n } finally {\n if (this.isCurrentNavigation(navigationId)) this.pageTransition.setLoadingState(false);\n }\n }\n async prefetch(path) {\n if (this.spaMode) {\n await this.pageLoader.prefetchSpaPageData(path);\n return;\n }\n await this.pageLoader.prefetch(path);\n }\n updatePage(data, targetPath) {\n if (!this.root) return;\n const isPopState = this.navigationHandlers.isPopState();\n const scrollY = this.navigationHandlers.getScrollPosition(targetPath);\n this.pageTransition.updatePage(data, isPopState, scrollY);\n this.navigationHandlers.clearPopStateFlag();\n }\n destroy() {\n this.navigationSequence++;\n this.pageTransition.setLoadingState(false);\n document.removeEventListener("click", this.handleClick);\n globalThis.removeEventListener("popstate", this.handlePopState);\n document.removeEventListener("mouseover", this.handleMouseOver);\n this.viewportPrefetch.disconnect();\n this.pageLoader.clearCache();\n this.navigationHandlers.clear();\n this.pageTransition.destroy();\n }\n};\nfunction boot(options = {}) {\n if (typeof window === "undefined" || !globalThis.document) return null;\n const globalWithRouter = globalThis;\n if (globalWithRouter.veryFrontRouter) return globalWithRouter.veryFrontRouter;\n const { slug: _slug, ...routerOptions } = options;\n const router = new VeryfrontRouter(routerOptions);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => router.init(), { once: true });\n } else {\n router.init();\n }\n globalWithRouter.veryFrontRouter = router;\n return router;\n}\nif (typeof window !== "undefined" && globalThis.document) {\n boot();\n}\nexport {\n VeryfrontRouter,\n boot\n};\n'; export const CLIENT_PREFETCH_BUNDLE: string | undefined = - 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-logger.ts\nvar ConditionalBrowserLogger = class {\n constructor(prefix, level) {\n __publicField(this, "prefix", prefix);\n __publicField(this, "level", level);\n }\n log(minLevel, fn, message, ...args) {\n if (this.level > minLevel) return;\n fn?.(message, ...args);\n }\n debug(message, ...args) {\n this.log(\n 0 /* DEBUG */,\n console.debug,\n `[${this.prefix}] DEBUG: ${message}`,\n ...args\n );\n }\n info(message, ...args) {\n this.log(1 /* INFO */, console.log, `[${this.prefix}] ${message}`, ...args);\n }\n warn(message, ...args) {\n this.log(\n 2 /* WARN */,\n console.warn,\n `[${this.prefix}] WARN: ${message}`,\n ...args\n );\n }\n error(message, ...args) {\n this.log(\n 3 /* ERROR */,\n console.error,\n `[${this.prefix}] ERROR: ${message}`,\n ...args\n );\n }\n};\nfunction getBrowserLogLevel() {\n if (typeof window === "undefined") return 2 /* WARN */;\n const g = globalThis;\n const isDevelopment = g.__VERYFRONT_DEV__ || g.__RSC_DEV__;\n if (!isDevelopment) return 2 /* WARN */;\n const isDebugEnabled2 = g.__VERYFRONT_DEBUG__ || g.__RSC_DEBUG__;\n return isDebugEnabled2 ? 0 /* DEBUG */ : 1 /* INFO */;\n}\nvar defaultLevel = getBrowserLogLevel();\nvar rscLogger = new ConditionalBrowserLogger("RSC", defaultLevel);\nvar prefetchLogger = new ConditionalBrowserLogger("PREFETCH", defaultLevel);\nvar hydrateLogger = new ConditionalBrowserLogger("HYDRATE", defaultLevel);\nvar browserLogger = new ConditionalBrowserLogger("VERYFRONT", defaultLevel);\n\n// src/rendering/client/prefetch/link-observer.ts\nfunction isAnchorElement(element) {\n return typeof HTMLAnchorElement !== "undefined" ? element instanceof HTMLAnchorElement : element.tagName === "A";\n}\nvar LinkObserver = class {\n constructor(options, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "intersectionObserver", null);\n __publicField(this, "mutationObserver", null);\n __publicField(this, "prefetchedUrls");\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "elementTimeoutMap", /* @__PURE__ */ new WeakMap());\n __publicField(this, "timeoutCounter", 0);\n this.options = options;\n this.prefetchedUrls = prefetchedUrls;\n }\n init() {\n this.createIntersectionObserver();\n this.observeLinks();\n this.setupMutationObserver();\n }\n createIntersectionObserver() {\n this.intersectionObserver = new IntersectionObserver(\n (entries) => this.handleIntersection(entries),\n { rootMargin: this.options.rootMargin }\n );\n }\n handleIntersection(entries) {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!isAnchorElement(entry.target)) continue;\n const link = entry.target;\n if (this.timeoutCounter > 1e6) this.timeoutCounter = 0;\n const timeoutKey = this.timeoutCounter++;\n const timeoutId = setTimeout(() => {\n this.pendingTimeouts.delete(timeoutKey);\n this.elementTimeoutMap.delete(link);\n this.options.onLinkVisible(link);\n }, this.options.delay);\n this.pendingTimeouts.set(timeoutKey, timeoutId);\n this.elementTimeoutMap.set(link, timeoutKey);\n }\n }\n observeLinks() {\n this.observeAnchors(document.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n setupMutationObserver() {\n this.mutationObserver = new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n if (mutation.type !== "childList") continue;\n for (const node of mutation.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.observeElement(node);\n }\n for (const node of mutation.removedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.clearElementTimeouts(node);\n }\n }\n });\n this.mutationObserver.observe(document.body, { childList: true, subtree: true });\n }\n clearTimeoutForElement(element) {\n const timeoutKey = this.elementTimeoutMap.get(element);\n if (timeoutKey === void 0) return;\n const timeoutId = this.pendingTimeouts.get(timeoutKey);\n if (timeoutId !== void 0) {\n clearTimeout(timeoutId);\n this.pendingTimeouts.delete(timeoutKey);\n }\n this.elementTimeoutMap.delete(element);\n }\n clearElementTimeouts(element) {\n if (isAnchorElement(element)) this.clearTimeoutForElement(element);\n for (const link of element.querySelectorAll("a")) {\n this.clearTimeoutForElement(link);\n }\n }\n observeElement(element) {\n if (isAnchorElement(element) && this.isValidLink(element)) {\n this.intersectionObserver?.observe(element);\n }\n this.observeAnchors(element.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n observeAnchors(links) {\n for (const link of links) {\n if (!isAnchorElement(link)) continue;\n if (!this.isValidLink(link)) continue;\n this.intersectionObserver?.observe(link);\n }\n }\n isValidLink(link) {\n if (link.hostname !== globalThis.location.hostname) return false;\n if (link.hasAttribute("download")) return false;\n if (link.target === "_blank") return false;\n const url = link.href;\n if (this.prefetchedUrls.has(url)) return false;\n if (url === globalThis.location.href) return false;\n if (link.hash && link.pathname === globalThis.location.pathname) return false;\n if (link.dataset.noPrefetch) return false;\n return true;\n }\n destroy() {\n for (const timeoutId of this.pendingTimeouts.values()) {\n clearTimeout(timeoutId);\n }\n this.pendingTimeouts.clear();\n this.timeoutCounter = 0;\n this.intersectionObserver?.disconnect();\n this.intersectionObserver = null;\n this.mutationObserver?.disconnect();\n this.mutationObserver = null;\n }\n};\n\n// src/rendering/client/prefetch/network-utils.ts\nvar NetworkUtils = class {\n constructor(allowedNetworks = ["4g", "wifi", "ethernet"]) {\n __publicField(this, "networkInfo");\n __publicField(this, "allowedNetworks");\n this.allowedNetworks = allowedNetworks;\n this.networkInfo = this.getNetworkConnection();\n }\n getNavigatorWithConnection() {\n if (typeof globalThis.navigator === "undefined") return null;\n return globalThis.navigator;\n }\n getNetworkConnection() {\n const nav = this.getNavigatorWithConnection();\n return nav?.connection ?? nav?.mozConnection ?? nav?.webkitConnection ?? null;\n }\n shouldPrefetch() {\n if (this.networkInfo?.saveData) return false;\n const effectiveType = this.networkInfo?.effectiveType;\n if (effectiveType != null && !this.allowedNetworks.includes(effectiveType)) return false;\n return true;\n }\n onNetworkChange(callback) {\n this.networkInfo?.addEventListener?.("change", callback);\n }\n getNetworkInfo() {\n return this.networkInfo;\n }\n};\n\n// src/utils/constants/buffers.ts\nvar DEFAULT_MAX_BODY_SIZE_BYTES = 1024 * 1024;\nvar DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;\nvar PREFETCH_QUEUE_MAX_SIZE_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES;\nvar MAX_BUNDLE_CHUNK_SIZE_BYTES = 4096 * 1024;\n\n// src/utils/constants/limits.ts\nvar MAX_TIMER_DELAY_MS = 2147483647;\n\n// src/utils/constants/cache.ts\nvar SECONDS_PER_MINUTE = 60;\nvar MINUTES_PER_HOUR = 60;\nvar HOURS_PER_DAY = 24;\nvar MS_PER_SECOND = 1e3;\nvar MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;\nvar MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE;\nvar ONE_DAY_MS = HOURS_PER_DAY * MS_PER_HOUR;\nfunction getEnvString(key) {\n const g = globalThis;\n try {\n return g.Deno?.env?.get?.(key) ?? g.process?.env?.[key];\n } catch (_) {\n return void 0;\n }\n}\nvar MAX_CONFIGURED_CACHE_ENTRIES = 1e6;\nvar MAX_CONFIGURED_CACHE_SIZE_MB = 64 * 1024;\nvar MAX_CONFIGURED_CONCURRENCY = 1e4;\nvar MAX_CONFIGURED_TTL_SECONDS = 365 * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar BYTES_PER_MB = 1024 * 1024;\nvar MAX_CACHE_TTL_SECONDS = 2147483647;\nvar MAX_CACHE_TTL_MILLISECONDS = MAX_CACHE_TTL_SECONDS * MS_PER_SECOND;\nfunction getEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) return fallback;\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) return fallback;\n return parsed;\n}\nfunction getStrictEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) {\n throw new RangeError(\n `${key} must be a base-10 integer between ${min} and ${max}`\n );\n }\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {\n throw new RangeError(`${key} must be between ${min} and ${max}`);\n }\n return parsed;\n}\nfunction getEnvCacheEntries(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_ENTRIES });\n}\nfunction getEnvCacheSizeMb(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_SIZE_MB });\n}\nfunction getEnvTtlSeconds(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_TTL_SECONDS });\n}\nvar DEFAULT_LRU_MAX_ENTRIES = getEnvCacheEntries("LRU_DEFAULT_MAX_ENTRIES", 100);\nvar COMPONENT_LOADER_MAX_ENTRIES = getEnvCacheEntries("COMPONENT_LOADER_MAX_ENTRIES", 200);\nvar COMPONENT_LOADER_TTL_MS = 10 * MS_PER_MINUTE;\nvar MDX_RENDERER_MAX_ENTRIES = getEnvCacheEntries("MDX_RENDERER_MAX_ENTRIES", 500);\nvar MDX_RENDERER_TTL_MS = 10 * MS_PER_MINUTE;\nvar RENDERER_CORE_MAX_ENTRIES = getEnvCacheEntries("RENDERER_CORE_MAX_ENTRIES", 200);\nvar RENDERER_CORE_TTL_MS = 5 * MS_PER_MINUTE;\nvar TSX_LAYOUT_MAX_ENTRIES = getEnvCacheEntries("TSX_LAYOUT_MAX_ENTRIES", 100);\nvar TSX_LAYOUT_TTL_MS = 10 * MS_PER_MINUTE;\nvar TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES = getEnvCacheEntries(\n "TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES",\n Math.ceil(TSX_LAYOUT_MAX_ENTRIES / 10)\n);\nvar DATA_FETCHING_MAX_ENTRIES = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES",\n 500,\n { max: MAX_CONFIGURED_CACHE_ENTRIES }\n);\nvar DATA_FETCHING_MAX_ENTRIES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES_PER_PROJECT",\n Math.max(1, Math.ceil(DATA_FETCHING_MAX_ENTRIES / 5)),\n { max: DATA_FETCHING_MAX_ENTRIES }\n);\nvar dataFetchingMaxSizeMb = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB",\n 50,\n { max: MAX_CONFIGURED_CACHE_SIZE_MB }\n);\nvar DATA_FETCHING_MAX_SIZE_BYTES = dataFetchingMaxSizeMb * BYTES_PER_MB;\nvar DATA_FETCHING_MAX_SIZE_BYTES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB_PER_PROJECT",\n Math.max(1, Math.ceil(dataFetchingMaxSizeMb / 5)),\n { max: dataFetchingMaxSizeMb }\n) * BYTES_PER_MB;\nvar DATA_FETCHING_TTL_MS = 10 * MS_PER_MINUTE;\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS",\n 512,\n { max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT",\n Math.min(128, DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS),\n { max: DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS }\n);\nvar MDX_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_MANIFEST_PROD_TTL_MS = 7 * ONE_DAY_MS;\nvar SERVER_ACTION_DEFAULT_TTL_SEC = MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar DISTRIBUTED_SSR_MODULE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_SEC",\n MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_PREVIEW_SEC",\n 5 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar LRU_DEFAULT_MAX_ENTRIES_V2 = getEnvCacheEntries("LRU_MAX_ENTRIES", 2e3);\nvar LRU_DEFAULT_MAX_SIZE_BYTES = getEnvCacheSizeMb("LRU_MAX_SIZE_MB", 200) * BYTES_PER_MB;\nvar MEMORY_CACHE_MAX_ENTRIES = getEnvCacheEntries("MEMORY_CACHE_MAX_ENTRIES", 2e3);\nvar MEMORY_CACHE_MAX_SIZE_BYTES = getEnvCacheSizeMb("MEMORY_CACHE_MAX_SIZE_MB", 50) * BYTES_PER_MB;\nvar FILE_CACHE_MAX_ENTRIES = getEnvCacheEntries("FILE_CACHE_MAX_ENTRIES", 1e3);\nvar FILE_CACHE_MAX_SIZE_MB = getEnvCacheSizeMb("FILE_CACHE_MAX_SIZE_MB", 100);\nvar MAX_CONCURRENT_REVALIDATIONS = getEnvInteger("MAX_CONCURRENT_REVALIDATIONS", 32, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar MAX_CONCURRENT_HTTP_FETCHES = getEnvInteger("MAX_CONCURRENT_HTTP_FETCHES", 50, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar REVALIDATION_TIMEOUT_MS = getEnvInteger("REVALIDATION_TIMEOUT_MS", 15e3, {\n max: MAX_TIMER_DELAY_MS\n});\nvar REVALIDATION_PER_PROJECT_LIMIT = getEnvInteger(\n "REVALIDATION_PER_PROJECT_LIMIT",\n Math.ceil(MAX_CONCURRENT_REVALIDATIONS / 3),\n { min: 0, max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar BUNDLE_MANIFEST_LRU_MAX_ENTRIES = getEnvCacheEntries(\n "BUNDLE_MANIFEST_LRU_MAX_ENTRIES",\n 5e3\n);\nvar BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_MB",\n 128\n) * BYTES_PER_MB;\nvar BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_MB",\n 256\n) * BYTES_PER_MB;\nvar HTTP_MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries(\n "HTTP_MODULE_CACHE_MAX_ENTRIES",\n 2e3\n);\nvar HTTP_MODULE_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "HTTP_MODULE_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar TRANSFORM_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "TRANSFORM_DISTRIBUTED_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 6 hours (21600)\n);\nvar MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries("MODULE_CACHE_MAX_ENTRIES", 1e4);\nvar MODULE_CACHE_TTL_MS = getEnvInteger(\n "MODULE_CACHE_TTL_MS",\n 5 * MS_PER_MINUTE,\n // 5 minutes - short enough to pick up changes, long enough to cache\n { max: MAX_TIMER_DELAY_MS }\n);\nvar ESM_CACHE_MAX_ENTRIES = getEnvCacheEntries("ESM_CACHE_MAX_ENTRIES", 5e3);\nvar ESM_CACHE_TTL_MS = getEnvInteger(\n "ESM_CACHE_TTL_MS",\n 10 * MS_PER_MINUTE,\n // 10 minutes - external modules change less frequently\n { max: MAX_TIMER_DELAY_MS }\n);\n\n// src/utils/constants/http.ts\nvar KB_IN_BYTES = 1024;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * KB_IN_BYTES;\n\n// src/utils/constants/hmr.ts\nvar HMR_MAX_MESSAGE_SIZE_BYTES = 1024 * KB_IN_BYTES;\n\n// src/utils/constants/network.ts\nvar BYTES_PER_KB = 1024;\nvar BYTES_PER_MB2 = BYTES_PER_KB * BYTES_PER_KB;\n\n// src/platform/compat/constants.ts\nvar LOCALHOST = Object.freeze(\n {\n IPV4: "127.0.0.1",\n IPV6: "::1",\n HOSTNAME: "localhost"\n }\n);\n\n// src/config/defaults.ts\nvar SSR_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;\n\n// src/utils/constants/server.ts\nvar INTERNAL_PREFIX = "/_veryfront";\nvar INTERNAL_PATH_PREFIXES = {\n /** React Server Components endpoints */\n RSC: `${INTERNAL_PREFIX}/rsc/`,\n /** File system access endpoints (base64 encoded paths) */\n FS: `${INTERNAL_PREFIX}/fs/`,\n /** Virtual module system */\n MODULES: `${INTERNAL_PREFIX}/modules/`,\n /** Generated page modules */\n PAGES: `${INTERNAL_PREFIX}/pages/`,\n /** Data JSON endpoints */\n DATA: `${INTERNAL_PREFIX}/data/`,\n /** Library modules and large vendor surfaces */\n LIB: `${INTERNAL_PREFIX}/lib/`,\n /** Chunk assets */\n CHUNKS: `${INTERNAL_PREFIX}/chunks/`,\n /** Client component modules */\n CLIENT: `${INTERNAL_PREFIX}/client/`\n};\nvar INTERNAL_ENDPOINTS = {\n // Development endpoints\n HMR_RUNTIME: `${INTERNAL_PREFIX}/hmr-runtime.js`,\n HMR: `${INTERNAL_PREFIX}/hmr.js`,\n ERROR_OVERLAY: `${INTERNAL_PREFIX}/error-overlay.js`,\n // Legacy endpoint retained for backward compatibility (no active handler).\n DEV_LOADER: `${INTERNAL_PREFIX}/dev-loader.js`,\n CLIENT_LOG: `${INTERNAL_PREFIX}/log`,\n // Production endpoints\n CLIENT_JS: `${INTERNAL_PREFIX}/client.js`,\n ROUTER_JS: `${INTERNAL_PREFIX}/router.js`,\n PREFETCH_JS: `${INTERNAL_PREFIX}/prefetch.js`,\n MANIFEST_JSON: `${INTERNAL_PREFIX}/manifest.json`,\n APP_JS: `${INTERNAL_PREFIX}/app.js`,\n // RSC endpoints\n RSC_CLIENT: `${INTERNAL_PREFIX}/rsc/client.js`,\n RSC_MANIFEST: `${INTERNAL_PREFIX}/rsc/manifest`,\n RSC_STREAM: `${INTERNAL_PREFIX}/rsc/stream`,\n RSC_PAYLOAD: `${INTERNAL_PREFIX}/rsc/payload`,\n RSC_RENDER: `${INTERNAL_PREFIX}/rsc/render`,\n RSC_PAGE: `${INTERNAL_PREFIX}/rsc/page`,\n RSC_MODULE: `${INTERNAL_PREFIX}/rsc/module`,\n RSC_DOM: `${INTERNAL_PREFIX}/rsc/dom.js`,\n // Library module endpoints\n LIB_CHAT_REACT: `${INTERNAL_PREFIX}/lib/chat/react.js`,\n LIB_CHAT_COMPONENTS: `${INTERNAL_PREFIX}/lib/chat/components.js`,\n LIB_CHAT_PRIMITIVES: `${INTERNAL_PREFIX}/lib/chat/primitives.js`\n};\nvar PROJECT_DIRS = {\n /** Base veryfront internal directory */\n ROOT: ".veryfront",\n /** Cache directory for build artifacts, transforms, etc. */\n CACHE: ".veryfront/cache",\n /** KV store directory */\n KV: ".veryfront/kv",\n /** Log files directory */\n LOGS: ".veryfront/logs",\n /** Temporary files directory */\n TMP: ".veryfront/tmp"\n};\nvar DEFAULT_CACHE_DIR = PROJECT_DIRS.CACHE;\nvar DEV_SERVER_ENDPOINTS = {\n HMR_RUNTIME: INTERNAL_ENDPOINTS.HMR_RUNTIME,\n ERROR_OVERLAY: INTERNAL_ENDPOINTS.ERROR_OVERLAY\n};\n\n// src/rendering/client/prefetch/prefetch-queue.ts\nvar DEFAULT_OPTIONS = {\n maxConcurrent: 4,\n maxSize: PREFETCH_QUEUE_MAX_SIZE_BYTES,\n timeout: 5e3\n};\nfunction isAbortError(error) {\n if (typeof error !== "object" || error === null) return false;\n if (!("name" in error)) return false;\n return error.name === "AbortError";\n}\nvar PrefetchQueue = class {\n constructor(options = {}, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "controllers", /* @__PURE__ */ new Map());\n __publicField(this, "prefetchedUrls");\n __publicField(this, "concurrent", 0);\n __publicField(this, "stopped", false);\n __publicField(this, "onResourcesFetched");\n this.options = { ...DEFAULT_OPTIONS, ...options };\n this.prefetchedUrls = prefetchedUrls ?? /* @__PURE__ */ new Set();\n }\n setResourceCallback(callback) {\n this.onResourcesFetched = callback;\n }\n enqueue(url) {\n void this.prefetch(url);\n }\n has(url) {\n return this.prefetchedUrls.has(url) || this.controllers.has(url);\n }\n get size() {\n return this.controllers.size;\n }\n clear() {\n this.stopAll();\n this.prefetchedUrls.clear();\n }\n start() {\n this.stopped = false;\n }\n stop() {\n this.stopped = true;\n this.stopAll();\n }\n getQueueSize() {\n return this.controllers.size;\n }\n getConcurrentCount() {\n return this.concurrent;\n }\n async prefetchLink(link) {\n if (this.stopped) return;\n const url = link.href;\n if (!url || this.controllers.has(url) || this.prefetchedUrls.has(url)) return;\n if (this.concurrent >= this.options.maxConcurrent) {\n prefetchLogger.debug?.(`Prefetch queue full, skipping ${url}`);\n return;\n }\n let parsedUrl;\n try {\n parsedUrl = new URL(url);\n } catch (_) {\n prefetchLogger.debug?.(`Invalid prefetch URL ${url}`);\n return;\n }\n const controller = new AbortController();\n this.controllers.set(url, controller);\n this.concurrent += 1;\n const timeoutId = this.options.timeout > 0 ? setTimeout(() => controller.abort(), this.options.timeout) : void 0;\n try {\n const response = await fetch(parsedUrl.toString(), {\n method: "GET",\n signal: controller.signal,\n headers: { "X-Veryfront-Prefetch": "1" }\n });\n if (!response.ok) return;\n if (this.isResponseTooLarge(response)) {\n prefetchLogger.debug?.(`Prefetch too large, skipping ${url}`);\n return;\n }\n this.prefetchedUrls.add(url);\n if (!this.onResourcesFetched) return;\n try {\n await this.onResourcesFetched(response, url);\n } catch (callbackError) {\n prefetchLogger.error?.(`Prefetch callback failed for ${url}`, callbackError);\n }\n } catch (error) {\n if (!isAbortError(error)) {\n prefetchLogger.error?.(`Failed to prefetch ${url}`, error);\n }\n } finally {\n if (timeoutId !== void 0) clearTimeout(timeoutId);\n this.controllers.delete(url);\n this.concurrent = Math.max(0, this.concurrent - 1);\n }\n }\n async prefetch(url) {\n const link = typeof document !== "undefined" ? document.createElement("a") : { href: url };\n link.href = url;\n await this.prefetchLink(link);\n }\n stopAll() {\n for (const controller of this.controllers.values()) {\n controller.abort();\n }\n this.controllers.clear();\n this.concurrent = 0;\n }\n isResponseTooLarge(response) {\n const rawLength = response.headers.get("content-length");\n if (rawLength === null) return false;\n const size = Number.parseInt(rawLength, 10);\n if (!Number.isFinite(size)) return false;\n return size > this.options.maxSize;\n }\n};\nvar prefetchQueue = new PrefetchQueue();\n\n// src/rendering/client/prefetch/resource-hints.ts\nvar ResourceHintsManager = class {\n constructor() {\n __publicField(this, "appliedHints", /* @__PURE__ */ new Set());\n }\n applyResourceHints(hints) {\n for (const hint of hints) {\n const key = `${hint.type}:${hint.href}`;\n if (this.appliedHints.has(key)) continue;\n const existing = document.querySelector(\n `link[rel="${hint.type}"][href="${hint.href}"]`\n );\n if (existing) {\n this.appliedHints.add(key);\n continue;\n }\n this.createAndAppendHint(hint);\n this.appliedHints.add(key);\n prefetchLogger.debug(`Added resource hint: ${hint.type} ${hint.href}`);\n }\n }\n createAndAppendHint(hint) {\n if (!document.head) {\n prefetchLogger.warn("document.head is not available, skipping resource hint");\n return;\n }\n const link = document.createElement("link");\n link.rel = hint.type;\n link.href = hint.href;\n if (hint.as) link.setAttribute("as", hint.as);\n if (hint.crossOrigin) link.setAttribute("crossorigin", hint.crossOrigin);\n if (hint.media) link.setAttribute("media", hint.media);\n document.head.appendChild(link);\n }\n extractResourceHints(html, prefetchedUrls) {\n try {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const hints = [];\n this.extractPreloadLinks(doc, prefetchedUrls, hints);\n this.extractScripts(doc, prefetchedUrls, hints);\n this.extractStylesheets(doc, prefetchedUrls, hints);\n return hints;\n } catch (error) {\n prefetchLogger.error("Failed to parse prefetched page", error);\n return [];\n }\n }\n isValidResourceHintType(rel) {\n switch (rel) {\n case "prefetch":\n case "preload":\n case "preconnect":\n case "dns-prefetch":\n return true;\n default:\n return false;\n }\n }\n extractPreloadLinks(doc, prefetchedUrls, hints) {\n const links = doc.querySelectorAll(\n \'link[rel="preload"], link[rel="prefetch"]\'\n );\n for (const link of links) {\n const href = link.href;\n if (!href) continue;\n if (prefetchedUrls.has(href)) continue;\n if (!this.isValidResourceHintType(link.rel)) continue;\n hints.push({\n type: link.rel,\n href,\n as: link.getAttribute("as") ?? void 0\n });\n }\n }\n extractScripts(doc, prefetchedUrls, hints) {\n for (const script of doc.querySelectorAll("script[src]")) {\n const src = script.src;\n if (!src || prefetchedUrls.has(src)) continue;\n hints.push({ type: "prefetch", href: src, as: "script" });\n }\n }\n extractStylesheets(doc, prefetchedUrls, hints) {\n for (const link of doc.querySelectorAll(\'link[rel="stylesheet"]\')) {\n const href = link.href;\n if (!href || prefetchedUrls.has(href)) continue;\n hints.push({ type: "prefetch", href, as: "style" });\n }\n }\n static generateResourceHints(_route, assets) {\n const hints = [\n \'\',\n \'\',\n \'\'\n ];\n for (const asset of assets) {\n if (asset.endsWith(".js")) {\n hints.push(``);\n continue;\n }\n if (asset.endsWith(".css")) {\n hints.push(``);\n continue;\n }\n if (/\\.(woff2?|ttf|otf)$/.test(asset)) {\n hints.push(``);\n }\n }\n return hints.join("\\n");\n }\n};\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar PREFETCH_MAX_SIZE_BYTES2 = 200 * 1024;\nvar PREFETCH_DEFAULT_TIMEOUT_MS2 = 1e4;\nvar PREFETCH_DEFAULT_DELAY_MS2 = 200;\n\n// src/rendering/client/prefetch.ts\nvar PrefetchManager = class {\n constructor(options = {}) {\n __publicField(this, "options");\n __publicField(this, "prefetchedUrls", /* @__PURE__ */ new Set());\n __publicField(this, "networkUtils");\n __publicField(this, "linkObserver", null);\n __publicField(this, "resourceHintsManager");\n __publicField(this, "prefetchQueue");\n this.options = {\n rootMargin: options.rootMargin ?? "50px",\n delay: options.delay ?? PREFETCH_DEFAULT_DELAY_MS2,\n maxConcurrent: options.maxConcurrent ?? 2,\n allowedNetworks: options.allowedNetworks ?? ["4g", "wifi", "ethernet"],\n maxSize: options.maxSize ?? PREFETCH_MAX_SIZE_BYTES2,\n timeout: options.timeout ?? PREFETCH_DEFAULT_TIMEOUT_MS2\n };\n this.networkUtils = new NetworkUtils(this.options.allowedNetworks);\n this.resourceHintsManager = new ResourceHintsManager();\n this.prefetchQueue = new PrefetchQueue(\n {\n maxConcurrent: this.options.maxConcurrent,\n maxSize: this.options.maxSize,\n timeout: this.options.timeout\n },\n this.prefetchedUrls\n );\n this.prefetchQueue.setResourceCallback(\n (response, url) => this.prefetchPageResources(response, url)\n );\n }\n init() {\n prefetchLogger.info("Initializing prefetch manager");\n if (!this.networkUtils.shouldPrefetch()) {\n prefetchLogger.info("Prefetching disabled due to network conditions");\n return;\n }\n this.linkObserver = new LinkObserver(\n {\n rootMargin: this.options.rootMargin,\n delay: this.options.delay,\n onLinkVisible: (link) => this.prefetchQueue.prefetchLink(link)\n },\n this.prefetchedUrls\n );\n this.linkObserver.init();\n this.networkUtils.onNetworkChange(() => {\n if (!this.networkUtils.shouldPrefetch()) this.prefetchQueue.stopAll();\n });\n }\n async prefetchPageResources(response, _pageUrl) {\n const html = await response.text();\n const hints = this.resourceHintsManager.extractResourceHints(html, this.prefetchedUrls);\n this.resourceHintsManager.applyResourceHints(hints);\n }\n applyResourceHints(hints) {\n this.resourceHintsManager.applyResourceHints(hints);\n }\n async prefetch(url) {\n await this.prefetchQueue.prefetch(url);\n }\n static generateResourceHints(route, assets) {\n return ResourceHintsManager.generateResourceHints(route, assets);\n }\n destroy() {\n this.linkObserver?.destroy();\n this.prefetchQueue.stopAll();\n this.prefetchedUrls.clear();\n }\n};\nfunction initPrefetch(options) {\n const prefetchManager = new PrefetchManager(options);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => prefetchManager.init(), { once: true });\n } else {\n prefetchManager.init();\n }\n globalThis.veryFrontPrefetch = prefetchManager;\n return prefetchManager;\n}\nfunction resolveAutoInitOptions() {\n const setting = globalThis.__VERYFRONT_PREFETCH__;\n if (!setting) return null;\n if (setting === true) return {};\n if (typeof setting === "object") return setting;\n return null;\n}\nfunction shouldAutoInitPrefetch(options) {\n if (!options) return false;\n if (typeof window === "undefined" || typeof document === "undefined") return false;\n const win = window;\n const doc = document;\n if (win.__veryfrontSSRStub || doc.__veryfrontSSRStub) return false;\n if (typeof IntersectionObserver === "undefined") return false;\n if (typeof MutationObserver === "undefined") return false;\n return true;\n}\nvar autoInitOptions = resolveAutoInitOptions();\nif (shouldAutoInitPrefetch(autoInitOptions)) initPrefetch(autoInitOptions);\nexport {\n PrefetchManager,\n initPrefetch\n};\n'; + 'var __defProp = Object.defineProperty;\nvar __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;\nvar __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);\n\n// src/rendering/client/browser-logger.ts\nvar ConditionalBrowserLogger = class {\n constructor(prefix, level) {\n __publicField(this, "prefix", prefix);\n __publicField(this, "level", level);\n }\n log(minLevel, fn, message, ...args) {\n if (this.level > minLevel) return;\n fn?.(message, ...args);\n }\n debug(message, ...args) {\n this.log(\n 0 /* DEBUG */,\n console.debug,\n `[${this.prefix}] DEBUG: ${message}`,\n ...args\n );\n }\n info(message, ...args) {\n this.log(1 /* INFO */, console.log, `[${this.prefix}] ${message}`, ...args);\n }\n warn(message, ...args) {\n this.log(\n 2 /* WARN */,\n console.warn,\n `[${this.prefix}] WARN: ${message}`,\n ...args\n );\n }\n error(message, ...args) {\n this.log(\n 3 /* ERROR */,\n console.error,\n `[${this.prefix}] ERROR: ${message}`,\n ...args\n );\n }\n};\nfunction getBrowserLogLevel() {\n if (typeof window === "undefined") return 2 /* WARN */;\n const g = globalThis;\n const isDevelopment = g.__VERYFRONT_DEV__ || g.__RSC_DEV__;\n if (!isDevelopment) return 2 /* WARN */;\n const isDebugEnabled2 = g.__VERYFRONT_DEBUG__ || g.__RSC_DEBUG__;\n return isDebugEnabled2 ? 0 /* DEBUG */ : 1 /* INFO */;\n}\nvar defaultLevel = getBrowserLogLevel();\nvar rscLogger = new ConditionalBrowserLogger("RSC", defaultLevel);\nvar prefetchLogger = new ConditionalBrowserLogger("PREFETCH", defaultLevel);\nvar hydrateLogger = new ConditionalBrowserLogger("HYDRATE", defaultLevel);\nvar browserLogger = new ConditionalBrowserLogger("VERYFRONT", defaultLevel);\n\n// src/rendering/client/prefetch/link-observer.ts\nfunction isAnchorElement(element) {\n return typeof HTMLAnchorElement !== "undefined" ? element instanceof HTMLAnchorElement : element.tagName === "A";\n}\nvar LinkObserver = class {\n constructor(options, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "intersectionObserver", null);\n __publicField(this, "mutationObserver", null);\n __publicField(this, "prefetchedUrls");\n __publicField(this, "pendingTimeouts", /* @__PURE__ */ new Map());\n __publicField(this, "elementTimeoutMap", /* @__PURE__ */ new WeakMap());\n __publicField(this, "timeoutCounter", 0);\n this.options = options;\n this.prefetchedUrls = prefetchedUrls;\n }\n init() {\n this.createIntersectionObserver();\n this.observeLinks();\n this.setupMutationObserver();\n }\n createIntersectionObserver() {\n this.intersectionObserver = new IntersectionObserver(\n (entries) => this.handleIntersection(entries),\n { rootMargin: this.options.rootMargin }\n );\n }\n handleIntersection(entries) {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (!isAnchorElement(entry.target)) continue;\n const link = entry.target;\n if (this.timeoutCounter > 1e6) this.timeoutCounter = 0;\n const timeoutKey = this.timeoutCounter++;\n const timeoutId = setTimeout(() => {\n this.pendingTimeouts.delete(timeoutKey);\n this.elementTimeoutMap.delete(link);\n this.options.onLinkVisible(link);\n }, this.options.delay);\n this.pendingTimeouts.set(timeoutKey, timeoutId);\n this.elementTimeoutMap.set(link, timeoutKey);\n }\n }\n observeLinks() {\n this.observeAnchors(document.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n setupMutationObserver() {\n this.mutationObserver = new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n if (mutation.type !== "childList") continue;\n for (const node of mutation.addedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.observeElement(node);\n }\n for (const node of mutation.removedNodes) {\n if (node.nodeType !== Node.ELEMENT_NODE) continue;\n this.clearElementTimeouts(node);\n }\n }\n });\n this.mutationObserver.observe(document.body, { childList: true, subtree: true });\n }\n clearTimeoutForElement(element) {\n const timeoutKey = this.elementTimeoutMap.get(element);\n if (timeoutKey === void 0) return;\n const timeoutId = this.pendingTimeouts.get(timeoutKey);\n if (timeoutId !== void 0) {\n clearTimeout(timeoutId);\n this.pendingTimeouts.delete(timeoutKey);\n }\n this.elementTimeoutMap.delete(element);\n }\n clearElementTimeouts(element) {\n if (isAnchorElement(element)) this.clearTimeoutForElement(element);\n for (const link of element.querySelectorAll("a")) {\n this.clearTimeoutForElement(link);\n }\n }\n observeElement(element) {\n if (isAnchorElement(element) && this.isValidLink(element)) {\n this.intersectionObserver?.observe(element);\n }\n this.observeAnchors(element.querySelectorAll(\'a[href^="/"], a[href^="./"]\'));\n }\n observeAnchors(links) {\n for (const link of links) {\n if (!isAnchorElement(link)) continue;\n if (!this.isValidLink(link)) continue;\n this.intersectionObserver?.observe(link);\n }\n }\n isValidLink(link) {\n if (link.hostname !== globalThis.location.hostname) return false;\n if (link.hasAttribute("download")) return false;\n if (link.target === "_blank") return false;\n const url = link.href;\n if (this.prefetchedUrls.has(url)) return false;\n if (url === globalThis.location.href) return false;\n if (link.hash && link.pathname === globalThis.location.pathname) return false;\n if (link.dataset.noPrefetch) return false;\n return true;\n }\n destroy() {\n for (const timeoutId of this.pendingTimeouts.values()) {\n clearTimeout(timeoutId);\n }\n this.pendingTimeouts.clear();\n this.timeoutCounter = 0;\n this.intersectionObserver?.disconnect();\n this.intersectionObserver = null;\n this.mutationObserver?.disconnect();\n this.mutationObserver = null;\n }\n};\n\n// src/rendering/client/prefetch/network-utils.ts\nvar NetworkUtils = class {\n constructor(allowedNetworks = ["4g", "wifi", "ethernet"]) {\n __publicField(this, "networkInfo");\n __publicField(this, "allowedNetworks");\n this.allowedNetworks = allowedNetworks;\n this.networkInfo = this.getNetworkConnection();\n }\n getNavigatorWithConnection() {\n if (typeof globalThis.navigator === "undefined") return null;\n return globalThis.navigator;\n }\n getNetworkConnection() {\n const nav = this.getNavigatorWithConnection();\n return nav?.connection ?? nav?.mozConnection ?? nav?.webkitConnection ?? null;\n }\n shouldPrefetch() {\n if (this.networkInfo?.saveData) return false;\n const effectiveType = this.networkInfo?.effectiveType;\n if (effectiveType != null && !this.allowedNetworks.includes(effectiveType)) return false;\n return true;\n }\n onNetworkChange(callback) {\n this.networkInfo?.addEventListener?.("change", callback);\n }\n getNetworkInfo() {\n return this.networkInfo;\n }\n};\n\n// src/utils/constants/css.ts\nvar MAX_CSS_FILE_BYTES = 16 * 1024 * 1024;\nvar MAX_CSS_TOTAL_BYTES = 64 * 1024 * 1024;\nvar MAX_CSS_OUTPUT_FILE_BYTES = 32 * 1024 * 1024;\n\n// src/utils/constants/buffers.ts\nvar DEFAULT_MAX_BODY_SIZE_BYTES = 1024 * 1024;\nvar DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;\nvar PREFETCH_QUEUE_MAX_SIZE_BYTES = DEFAULT_MAX_BODY_SIZE_BYTES;\nvar MAX_BUNDLE_CHUNK_SIZE_BYTES = 4096 * 1024;\n\n// src/utils/constants/limits.ts\nvar MAX_TIMER_DELAY_MS = 2147483647;\n\n// src/utils/constants/cache.ts\nvar SECONDS_PER_MINUTE = 60;\nvar MINUTES_PER_HOUR = 60;\nvar HOURS_PER_DAY = 24;\nvar MS_PER_SECOND = 1e3;\nvar MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND;\nvar MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE;\nvar ONE_DAY_MS = HOURS_PER_DAY * MS_PER_HOUR;\nfunction getEnvString(key) {\n const g = globalThis;\n try {\n return g.Deno?.env?.get?.(key) ?? g.process?.env?.[key];\n } catch (_) {\n return void 0;\n }\n}\nvar MAX_CONFIGURED_CACHE_ENTRIES = 1e6;\nvar MAX_CONFIGURED_CACHE_SIZE_MB = 64 * 1024;\nvar MAX_CONFIGURED_CONCURRENCY = 1e4;\nvar MAX_CONFIGURED_TTL_SECONDS = 365 * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar BYTES_PER_MB = 1024 * 1024;\nvar MAX_CACHE_TTL_SECONDS = 2147483647;\nvar MAX_CACHE_TTL_MILLISECONDS = MAX_CACHE_TTL_SECONDS * MS_PER_SECOND;\nfunction getEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) return fallback;\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) return fallback;\n return parsed;\n}\nfunction getStrictEnvInteger(key, fallback, { min = 1, max }) {\n const value = getEnvString(key);\n if (value == null) return fallback;\n const normalized = value.trim();\n if (!/^\\d+$/.test(normalized)) {\n throw new RangeError(\n `${key} must be a base-10 integer between ${min} and ${max}`\n );\n }\n const parsed = Number(normalized);\n if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {\n throw new RangeError(`${key} must be between ${min} and ${max}`);\n }\n return parsed;\n}\nfunction getEnvCacheEntries(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_ENTRIES });\n}\nfunction getEnvCacheSizeMb(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_CACHE_SIZE_MB });\n}\nfunction getEnvTtlSeconds(key, fallback) {\n return getEnvInteger(key, fallback, { max: MAX_CONFIGURED_TTL_SECONDS });\n}\nvar DEFAULT_LRU_MAX_ENTRIES = getEnvCacheEntries("LRU_DEFAULT_MAX_ENTRIES", 100);\nvar COMPONENT_LOADER_MAX_ENTRIES = getEnvCacheEntries("COMPONENT_LOADER_MAX_ENTRIES", 200);\nvar COMPONENT_LOADER_TTL_MS = 10 * MS_PER_MINUTE;\nvar MDX_RENDERER_MAX_ENTRIES = getEnvCacheEntries("MDX_RENDERER_MAX_ENTRIES", 500);\nvar MDX_RENDERER_TTL_MS = 10 * MS_PER_MINUTE;\nvar RENDERER_CORE_MAX_ENTRIES = getEnvCacheEntries("RENDERER_CORE_MAX_ENTRIES", 200);\nvar RENDERER_CORE_TTL_MS = 5 * MS_PER_MINUTE;\nvar TSX_LAYOUT_MAX_ENTRIES = getEnvCacheEntries("TSX_LAYOUT_MAX_ENTRIES", 100);\nvar TSX_LAYOUT_TTL_MS = 10 * MS_PER_MINUTE;\nvar TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES = getEnvCacheEntries(\n "TSX_LAYOUT_PER_PROJECT_MAX_ENTRIES",\n Math.ceil(TSX_LAYOUT_MAX_ENTRIES / 10)\n);\nvar DATA_FETCHING_MAX_ENTRIES = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES",\n 500,\n { max: MAX_CONFIGURED_CACHE_ENTRIES }\n);\nvar DATA_FETCHING_MAX_ENTRIES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_ENTRIES_PER_PROJECT",\n Math.max(1, Math.ceil(DATA_FETCHING_MAX_ENTRIES / 5)),\n { max: DATA_FETCHING_MAX_ENTRIES }\n);\nvar dataFetchingMaxSizeMb = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB",\n 50,\n { max: MAX_CONFIGURED_CACHE_SIZE_MB }\n);\nvar DATA_FETCHING_MAX_SIZE_BYTES = dataFetchingMaxSizeMb * BYTES_PER_MB;\nvar DATA_FETCHING_MAX_SIZE_BYTES_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_SIZE_MB_PER_PROJECT",\n Math.max(1, Math.ceil(dataFetchingMaxSizeMb / 5)),\n { max: dataFetchingMaxSizeMb }\n) * BYTES_PER_MB;\nvar DATA_FETCHING_TTL_MS = 10 * MS_PER_MINUTE;\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS",\n 512,\n { max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT = getStrictEnvInteger(\n "DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS_PER_PROJECT",\n Math.min(128, DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS),\n { max: DATA_FETCHING_MAX_CONCURRENT_EXECUTIONS }\n);\nvar MDX_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_CACHE_TTL_DEVELOPMENT_MS = 5 * MS_PER_MINUTE;\nvar BUNDLE_MANIFEST_PROD_TTL_MS = 7 * ONE_DAY_MS;\nvar SERVER_ACTION_DEFAULT_TTL_SEC = MINUTES_PER_HOUR * SECONDS_PER_MINUTE;\nvar DISTRIBUTED_SSR_MODULE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_SSR_MODULE_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_TRANSFORM_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_SEC",\n MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_FILE_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_FILE_TTL_PREVIEW_SEC",\n 5 * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PRODUCTION_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n);\nvar DISTRIBUTED_CSS_TTL_PREVIEW_SEC = getEnvTtlSeconds(\n "DISTRIBUTED_CSS_TTL_PREVIEW_SEC",\n 10 * SECONDS_PER_MINUTE\n);\nvar LRU_DEFAULT_MAX_ENTRIES_V2 = getEnvCacheEntries("LRU_MAX_ENTRIES", 2e3);\nvar LRU_DEFAULT_MAX_SIZE_BYTES = getEnvCacheSizeMb("LRU_MAX_SIZE_MB", 200) * BYTES_PER_MB;\nvar MEMORY_CACHE_MAX_ENTRIES = getEnvCacheEntries("MEMORY_CACHE_MAX_ENTRIES", 2e3);\nvar MEMORY_CACHE_MAX_SIZE_BYTES = getEnvCacheSizeMb("MEMORY_CACHE_MAX_SIZE_MB", 50) * BYTES_PER_MB;\nvar FILE_CACHE_MAX_ENTRIES = getEnvCacheEntries("FILE_CACHE_MAX_ENTRIES", 1e3);\nvar FILE_CACHE_MAX_SIZE_MB = getEnvCacheSizeMb("FILE_CACHE_MAX_SIZE_MB", 100);\nvar MAX_CONCURRENT_REVALIDATIONS = getEnvInteger("MAX_CONCURRENT_REVALIDATIONS", 32, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar MAX_CONCURRENT_HTTP_FETCHES = getEnvInteger("MAX_CONCURRENT_HTTP_FETCHES", 50, {\n max: MAX_CONFIGURED_CONCURRENCY\n});\nvar REVALIDATION_TIMEOUT_MS = getEnvInteger("REVALIDATION_TIMEOUT_MS", 15e3, {\n max: MAX_TIMER_DELAY_MS\n});\nvar REVALIDATION_PER_PROJECT_LIMIT = getEnvInteger(\n "REVALIDATION_PER_PROJECT_LIMIT",\n Math.ceil(MAX_CONCURRENT_REVALIDATIONS / 3),\n { min: 0, max: MAX_CONFIGURED_CONCURRENCY }\n);\nvar BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "BUNDLE_MANIFEST_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar BUNDLE_MANIFEST_LRU_MAX_ENTRIES = getEnvCacheEntries(\n "BUNDLE_MANIFEST_LRU_MAX_ENTRIES",\n 5e3\n);\nvar BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_METADATA_SIZE_MB",\n 128\n) * BYTES_PER_MB;\nvar BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_BYTES = getEnvCacheSizeMb(\n "BUNDLE_MANIFEST_MEMORY_MAX_CODE_SIZE_MB",\n 256\n) * BYTES_PER_MB;\nvar HTTP_MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries(\n "HTTP_MODULE_CACHE_MAX_ENTRIES",\n 2e3\n);\nvar HTTP_MODULE_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "HTTP_MODULE_DISTRIBUTED_TTL_SEC",\n HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 24 hours (86400)\n);\nvar TRANSFORM_DISTRIBUTED_TTL_SEC = getEnvTtlSeconds(\n "TRANSFORM_DISTRIBUTED_TTL_SEC",\n 6 * MINUTES_PER_HOUR * SECONDS_PER_MINUTE\n // 6 hours (21600)\n);\nvar MODULE_CACHE_MAX_ENTRIES = getEnvCacheEntries("MODULE_CACHE_MAX_ENTRIES", 1e4);\nvar MODULE_CACHE_TTL_MS = getEnvInteger(\n "MODULE_CACHE_TTL_MS",\n 5 * MS_PER_MINUTE,\n // 5 minutes - short enough to pick up changes, long enough to cache\n { max: MAX_TIMER_DELAY_MS }\n);\nvar ESM_CACHE_MAX_ENTRIES = getEnvCacheEntries("ESM_CACHE_MAX_ENTRIES", 5e3);\nvar ESM_CACHE_TTL_MS = getEnvInteger(\n "ESM_CACHE_TTL_MS",\n 10 * MS_PER_MINUTE,\n // 10 minutes - external modules change less frequently\n { max: MAX_TIMER_DELAY_MS }\n);\n\n// src/utils/constants/http.ts\nvar KB_IN_BYTES = 1024;\nvar PREFETCH_MAX_SIZE_BYTES = 200 * KB_IN_BYTES;\n\n// src/utils/constants/hmr.ts\nvar HMR_MAX_MESSAGE_SIZE_BYTES = 1024 * KB_IN_BYTES;\n\n// src/utils/constants/network.ts\nvar BYTES_PER_KB = 1024;\nvar BYTES_PER_MB2 = BYTES_PER_KB * BYTES_PER_KB;\n\n// src/utils/constants/security.ts\nvar MAX_CSRF_TTL_SECONDS = Number.MAX_SAFE_INTEGER;\n\n// src/platform/compat/constants.ts\nvar DEFAULT_PORT = 3e3;\nvar LOCALHOST = Object.freeze(\n {\n IPV4: "127.0.0.1",\n IPV6: "::1",\n HOSTNAME: "localhost"\n }\n);\n\n// src/config/defaults.ts\nvar DEFAULT_TIMEOUT_MS = 5e3;\nvar SSR_TIMEOUT_MS = 1e4;\nvar SSR_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;\nvar SANDBOX_TIMEOUT_MS = 5e3;\nvar DEFAULT_CACHE_MAX_SIZE = 100;\nvar DURATION_HISTOGRAM_BOUNDARIES_MS = Object.freeze(\n [\n 5,\n 10,\n 25,\n 50,\n 75,\n 100,\n 250,\n 500,\n 750,\n 1e3,\n 2500,\n 5e3,\n 7500,\n 1e4\n ]\n);\nvar SIZE_HISTOGRAM_BOUNDARIES_KB = Object.freeze(\n [\n 1,\n 5,\n 10,\n 25,\n 50,\n 100,\n 250,\n 500,\n 1e3,\n 2500,\n 5e3,\n 1e4\n ]\n);\nvar defaultConfig = Object.freeze(\n {\n server: Object.freeze({\n port: DEFAULT_PORT,\n hostname: "0.0.0.0"\n }),\n timeouts: Object.freeze({\n default: DEFAULT_TIMEOUT_MS,\n api: 3e4,\n ssr: SSR_TIMEOUT_MS,\n hmr: 3e4,\n sandbox: SANDBOX_TIMEOUT_MS\n }),\n cache: Object.freeze({\n jit: Object.freeze({\n maxSize: DEFAULT_CACHE_MAX_SIZE,\n tempDirPrefix: "vf-bundle-"\n })\n }),\n metrics: Object.freeze({\n ssrBoundaries: DURATION_HISTOGRAM_BOUNDARIES_MS\n })\n }\n);\n\n// src/utils/constants/server.ts\nvar INTERNAL_PREFIX = "/_veryfront";\nvar INTERNAL_PATH_PREFIXES = {\n /** React Server Components endpoints */\n RSC: `${INTERNAL_PREFIX}/rsc/`,\n /** File system access endpoints (base64 encoded paths) */\n FS: `${INTERNAL_PREFIX}/fs/`,\n /** Virtual module system */\n MODULES: `${INTERNAL_PREFIX}/modules/`,\n /** Generated page modules */\n PAGES: `${INTERNAL_PREFIX}/pages/`,\n /** Data JSON endpoints */\n DATA: `${INTERNAL_PREFIX}/data/`,\n /** Library modules and large vendor surfaces */\n LIB: `${INTERNAL_PREFIX}/lib/`,\n /** Chunk assets */\n CHUNKS: `${INTERNAL_PREFIX}/chunks/`,\n /** Client component modules */\n CLIENT: `${INTERNAL_PREFIX}/client/`\n};\nvar INTERNAL_ENDPOINTS = {\n // Development endpoints\n HMR_RUNTIME: `${INTERNAL_PREFIX}/hmr-runtime.js`,\n HMR: `${INTERNAL_PREFIX}/hmr.js`,\n ERROR_OVERLAY: `${INTERNAL_PREFIX}/error-overlay.js`,\n // Legacy endpoint retained for backward compatibility (no active handler).\n DEV_LOADER: `${INTERNAL_PREFIX}/dev-loader.js`,\n CLIENT_LOG: `${INTERNAL_PREFIX}/log`,\n // Production endpoints\n CLIENT_JS: `${INTERNAL_PREFIX}/client.js`,\n ROUTER_JS: `${INTERNAL_PREFIX}/router.js`,\n PREFETCH_JS: `${INTERNAL_PREFIX}/prefetch.js`,\n MANIFEST_JSON: `${INTERNAL_PREFIX}/manifest.json`,\n APP_JS: `${INTERNAL_PREFIX}/app.js`,\n // RSC endpoints\n RSC_CLIENT: `${INTERNAL_PREFIX}/rsc/client.js`,\n RSC_MANIFEST: `${INTERNAL_PREFIX}/rsc/manifest`,\n RSC_STREAM: `${INTERNAL_PREFIX}/rsc/stream`,\n RSC_PAYLOAD: `${INTERNAL_PREFIX}/rsc/payload`,\n RSC_RENDER: `${INTERNAL_PREFIX}/rsc/render`,\n RSC_PAGE: `${INTERNAL_PREFIX}/rsc/page`,\n RSC_MODULE: `${INTERNAL_PREFIX}/rsc/module`,\n RSC_DOM: `${INTERNAL_PREFIX}/rsc/dom.js`,\n // Library module endpoints\n LIB_CHAT_REACT: `${INTERNAL_PREFIX}/lib/chat/react.js`,\n LIB_CHAT_COMPONENTS: `${INTERNAL_PREFIX}/lib/chat/components.js`,\n LIB_CHAT_PRIMITIVES: `${INTERNAL_PREFIX}/lib/chat/primitives.js`\n};\nvar PROJECT_DIRS = {\n /** Base veryfront internal directory */\n ROOT: ".veryfront",\n /** Cache directory for build artifacts, transforms, etc. */\n CACHE: ".veryfront/cache",\n /** KV store directory */\n KV: ".veryfront/kv",\n /** Log files directory */\n LOGS: ".veryfront/logs",\n /** Temporary files directory */\n TMP: ".veryfront/tmp"\n};\nvar DEFAULT_CACHE_DIR = PROJECT_DIRS.CACHE;\nvar DEV_SERVER_ENDPOINTS = {\n HMR_RUNTIME: INTERNAL_ENDPOINTS.HMR_RUNTIME,\n ERROR_OVERLAY: INTERNAL_ENDPOINTS.ERROR_OVERLAY\n};\n\n// src/rendering/client/prefetch/prefetch-queue.ts\nvar DEFAULT_OPTIONS = {\n maxConcurrent: 4,\n maxSize: PREFETCH_QUEUE_MAX_SIZE_BYTES,\n timeout: 5e3\n};\nfunction isAbortError(error) {\n if (typeof error !== "object" || error === null) return false;\n if (!("name" in error)) return false;\n return error.name === "AbortError";\n}\nvar PrefetchQueue = class {\n constructor(options = {}, prefetchedUrls) {\n __publicField(this, "options");\n __publicField(this, "controllers", /* @__PURE__ */ new Map());\n __publicField(this, "prefetchedUrls");\n __publicField(this, "concurrent", 0);\n __publicField(this, "stopped", false);\n __publicField(this, "onResourcesFetched");\n this.options = { ...DEFAULT_OPTIONS, ...options };\n this.prefetchedUrls = prefetchedUrls ?? /* @__PURE__ */ new Set();\n }\n setResourceCallback(callback) {\n this.onResourcesFetched = callback;\n }\n enqueue(url) {\n void this.prefetch(url);\n }\n has(url) {\n return this.prefetchedUrls.has(url) || this.controllers.has(url);\n }\n get size() {\n return this.controllers.size;\n }\n clear() {\n this.stopAll();\n this.prefetchedUrls.clear();\n }\n start() {\n this.stopped = false;\n }\n stop() {\n this.stopped = true;\n this.stopAll();\n }\n getQueueSize() {\n return this.controllers.size;\n }\n getConcurrentCount() {\n return this.concurrent;\n }\n async prefetchLink(link) {\n if (this.stopped) return;\n const url = link.href;\n if (!url || this.controllers.has(url) || this.prefetchedUrls.has(url)) return;\n if (this.concurrent >= this.options.maxConcurrent) {\n prefetchLogger.debug?.(`Prefetch queue full, skipping ${url}`);\n return;\n }\n let parsedUrl;\n try {\n parsedUrl = new URL(url);\n } catch (_) {\n prefetchLogger.debug?.(`Invalid prefetch URL ${url}`);\n return;\n }\n const controller = new AbortController();\n this.controllers.set(url, controller);\n this.concurrent += 1;\n const timeoutId = this.options.timeout > 0 ? setTimeout(() => controller.abort(), this.options.timeout) : void 0;\n try {\n const response = await fetch(parsedUrl.toString(), {\n method: "GET",\n signal: controller.signal,\n headers: { "X-Veryfront-Prefetch": "1" }\n });\n if (!response.ok) return;\n if (this.isResponseTooLarge(response)) {\n prefetchLogger.debug?.(`Prefetch too large, skipping ${url}`);\n return;\n }\n this.prefetchedUrls.add(url);\n if (!this.onResourcesFetched) return;\n try {\n await this.onResourcesFetched(response, url);\n } catch (callbackError) {\n prefetchLogger.error?.(`Prefetch callback failed for ${url}`, callbackError);\n }\n } catch (error) {\n if (!isAbortError(error)) {\n prefetchLogger.error?.(`Failed to prefetch ${url}`, error);\n }\n } finally {\n if (timeoutId !== void 0) clearTimeout(timeoutId);\n this.controllers.delete(url);\n this.concurrent = Math.max(0, this.concurrent - 1);\n }\n }\n async prefetch(url) {\n const link = typeof document !== "undefined" ? document.createElement("a") : { href: url };\n link.href = url;\n await this.prefetchLink(link);\n }\n stopAll() {\n for (const controller of this.controllers.values()) {\n controller.abort();\n }\n this.controllers.clear();\n this.concurrent = 0;\n }\n isResponseTooLarge(response) {\n const rawLength = response.headers.get("content-length");\n if (rawLength === null) return false;\n const size = Number.parseInt(rawLength, 10);\n if (!Number.isFinite(size)) return false;\n return size > this.options.maxSize;\n }\n};\nvar prefetchQueue = new PrefetchQueue();\n\n// src/rendering/client/prefetch/resource-hints.ts\nvar ResourceHintsManager = class {\n constructor() {\n __publicField(this, "appliedHints", /* @__PURE__ */ new Set());\n }\n applyResourceHints(hints) {\n for (const hint of hints) {\n const key = `${hint.type}:${hint.href}`;\n if (this.appliedHints.has(key)) continue;\n const existing = document.querySelector(\n `link[rel="${hint.type}"][href="${hint.href}"]`\n );\n if (existing) {\n this.appliedHints.add(key);\n continue;\n }\n this.createAndAppendHint(hint);\n this.appliedHints.add(key);\n prefetchLogger.debug(`Added resource hint: ${hint.type} ${hint.href}`);\n }\n }\n createAndAppendHint(hint) {\n if (!document.head) {\n prefetchLogger.warn("document.head is not available, skipping resource hint");\n return;\n }\n const link = document.createElement("link");\n link.rel = hint.type;\n link.href = hint.href;\n if (hint.as) link.setAttribute("as", hint.as);\n if (hint.crossOrigin) link.setAttribute("crossorigin", hint.crossOrigin);\n if (hint.media) link.setAttribute("media", hint.media);\n document.head.appendChild(link);\n }\n extractResourceHints(html, prefetchedUrls) {\n try {\n const doc = new DOMParser().parseFromString(html, "text/html");\n const hints = [];\n this.extractPreloadLinks(doc, prefetchedUrls, hints);\n this.extractScripts(doc, prefetchedUrls, hints);\n this.extractStylesheets(doc, prefetchedUrls, hints);\n return hints;\n } catch (error) {\n prefetchLogger.error("Failed to parse prefetched page", error);\n return [];\n }\n }\n isValidResourceHintType(rel) {\n switch (rel) {\n case "prefetch":\n case "preload":\n case "preconnect":\n case "dns-prefetch":\n return true;\n default:\n return false;\n }\n }\n extractPreloadLinks(doc, prefetchedUrls, hints) {\n const links = doc.querySelectorAll(\n \'link[rel="preload"], link[rel="prefetch"]\'\n );\n for (const link of links) {\n const href = link.href;\n if (!href) continue;\n if (prefetchedUrls.has(href)) continue;\n if (!this.isValidResourceHintType(link.rel)) continue;\n hints.push({\n type: link.rel,\n href,\n as: link.getAttribute("as") ?? void 0\n });\n }\n }\n extractScripts(doc, prefetchedUrls, hints) {\n for (const script of doc.querySelectorAll("script[src]")) {\n const src = script.src;\n if (!src || prefetchedUrls.has(src)) continue;\n hints.push({ type: "prefetch", href: src, as: "script" });\n }\n }\n extractStylesheets(doc, prefetchedUrls, hints) {\n for (const link of doc.querySelectorAll(\'link[rel="stylesheet"]\')) {\n const href = link.href;\n if (!href || prefetchedUrls.has(href)) continue;\n hints.push({ type: "prefetch", href, as: "style" });\n }\n }\n static generateResourceHints(_route, assets) {\n const hints = [\n \'\',\n \'\',\n \'\'\n ];\n for (const asset of assets) {\n if (asset.endsWith(".js")) {\n hints.push(``);\n continue;\n }\n if (asset.endsWith(".css")) {\n hints.push(``);\n continue;\n }\n if (/\\.(woff2?|ttf|otf)$/.test(asset)) {\n hints.push(``);\n }\n }\n return hints.join("\\n");\n }\n};\n\n// src/rendering/client/browser-stubs/logger.ts\nfunction noop() {\n}\nvar logger = {\n debug: noop,\n info: console.log.bind(console),\n warn: console.warn.bind(console),\n error: console.error.bind(console),\n component: () => logger\n};\nvar PREFETCH_MAX_SIZE_BYTES2 = 200 * 1024;\nvar PREFETCH_DEFAULT_TIMEOUT_MS2 = 1e4;\nvar PREFETCH_DEFAULT_DELAY_MS2 = 200;\n\n// src/rendering/client/prefetch.ts\nvar PrefetchManager = class {\n constructor(options = {}) {\n __publicField(this, "options");\n __publicField(this, "prefetchedUrls", /* @__PURE__ */ new Set());\n __publicField(this, "networkUtils");\n __publicField(this, "linkObserver", null);\n __publicField(this, "resourceHintsManager");\n __publicField(this, "prefetchQueue");\n this.options = {\n rootMargin: options.rootMargin ?? "50px",\n delay: options.delay ?? PREFETCH_DEFAULT_DELAY_MS2,\n maxConcurrent: options.maxConcurrent ?? 2,\n allowedNetworks: options.allowedNetworks ?? ["4g", "wifi", "ethernet"],\n maxSize: options.maxSize ?? PREFETCH_MAX_SIZE_BYTES2,\n timeout: options.timeout ?? PREFETCH_DEFAULT_TIMEOUT_MS2\n };\n this.networkUtils = new NetworkUtils(this.options.allowedNetworks);\n this.resourceHintsManager = new ResourceHintsManager();\n this.prefetchQueue = new PrefetchQueue(\n {\n maxConcurrent: this.options.maxConcurrent,\n maxSize: this.options.maxSize,\n timeout: this.options.timeout\n },\n this.prefetchedUrls\n );\n this.prefetchQueue.setResourceCallback(\n (response, url) => this.prefetchPageResources(response, url)\n );\n }\n init() {\n prefetchLogger.info("Initializing prefetch manager");\n if (!this.networkUtils.shouldPrefetch()) {\n prefetchLogger.info("Prefetching disabled due to network conditions");\n return;\n }\n this.linkObserver = new LinkObserver(\n {\n rootMargin: this.options.rootMargin,\n delay: this.options.delay,\n onLinkVisible: (link) => this.prefetchQueue.prefetchLink(link)\n },\n this.prefetchedUrls\n );\n this.linkObserver.init();\n this.networkUtils.onNetworkChange(() => {\n if (!this.networkUtils.shouldPrefetch()) this.prefetchQueue.stopAll();\n });\n }\n async prefetchPageResources(response, _pageUrl) {\n const html = await response.text();\n const hints = this.resourceHintsManager.extractResourceHints(html, this.prefetchedUrls);\n this.resourceHintsManager.applyResourceHints(hints);\n }\n applyResourceHints(hints) {\n this.resourceHintsManager.applyResourceHints(hints);\n }\n async prefetch(url) {\n await this.prefetchQueue.prefetch(url);\n }\n static generateResourceHints(route, assets) {\n return ResourceHintsManager.generateResourceHints(route, assets);\n }\n destroy() {\n this.linkObserver?.destroy();\n this.prefetchQueue.stopAll();\n this.prefetchedUrls.clear();\n }\n};\nfunction initPrefetch(options) {\n const prefetchManager = new PrefetchManager(options);\n if (document.readyState === "loading") {\n document.addEventListener("DOMContentLoaded", () => prefetchManager.init(), { once: true });\n } else {\n prefetchManager.init();\n }\n globalThis.veryFrontPrefetch = prefetchManager;\n return prefetchManager;\n}\nfunction resolveAutoInitOptions() {\n const setting = globalThis.__VERYFRONT_PREFETCH__;\n if (!setting) return null;\n if (setting === true) return {};\n if (typeof setting === "object") return setting;\n return null;\n}\nfunction shouldAutoInitPrefetch(options) {\n if (!options) return false;\n if (typeof window === "undefined" || typeof document === "undefined") return false;\n const win = window;\n const doc = document;\n if (win.__veryfrontSSRStub || doc.__veryfrontSSRStub) return false;\n if (typeof IntersectionObserver === "undefined") return false;\n if (typeof MutationObserver === "undefined") return false;\n return true;\n}\nvar autoInitOptions = resolveAutoInitOptions();\nif (shouldAutoInitPrefetch(autoInitOptions)) initPrefetch(autoInitOptions);\nexport {\n PrefetchManager,\n initPrefetch\n};\n'; diff --git a/src/config/README.md b/src/config/README.md index 997c6ca945..e18db84382 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -1,42 +1,118 @@ # Config Module -This module manages all configuration for the Veryfront renderer. +This module owns project-config discovery, validation and caching, the hosted +declarative evaluation boundary, process environment snapshots, runtime-config +helpers, and shared network defaults. ## Configuration Hierarchy -| Layer | Type | Source | Purpose | -| ---------------------- | ------------------- | --------------------- | ---------------------------------------- | -| **Project Config** | `VeryfrontConfig` | `veryfront.config.ts` | Per-project settings defined by the user | -| **Environment Config** | `EnvironmentConfig` | Environment variables | System-level settings from env vars | -| **Runtime Config** | `RuntimeConfig` | Merged at startup | Combined config with runtime info | +| Layer | Type | Source | Purpose | +| ---------------------- | ------------------- | --------------------------------------- | -------------------------------- | +| **Project Config** | `VeryfrontConfig` | `veryfront.config.js`, `.ts`, or `.mjs` | Validated per-project settings | +| **Environment Config** | `EnvironmentConfig` | Environment variables | Process-owned environment state | +| **Runtime Config** | `RuntimeConfig` | Explicit caller input | Opt-in config plus runtime flags | ## Project Config (`VeryfrontConfig`) -User-defined configuration from `veryfront.config.ts` in the project root. +User-defined configuration from the project root. Discovery uses one canonical +precedence order: `veryfront.config.js`, then `veryfront.config.ts`, then +`veryfront.config.mjs`. ```typescript import { defineConfig } from "veryfront"; export default defineConfig({ - app: { name: "My App" }, - build: { target: "es2022" }, - router: { trailingSlash: false }, - // ... other settings + projectSlug: "my-app", + app: "components/app.tsx", + build: { ssg: true }, + router: "app", }); ``` -**Key properties:** `app`, `build`, `cache`, `dev`, `router`, `theme`, `security`, `middleware`, etc. +### Built-in consumption contract + +Schema acceptance does not by itself mean core implements behavior for a +field. The complete validated config is also passed to extensions and included +in render-cache identity, so compatibility-only fields cannot be removed as +incidental cleanup. + +| Ownership | Fields | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core runtime/build | `projectSlug`, `react.version`, `directories.app/pages/components`, `router`, `layout`, `app`, `experimental.esmLayouts/rsc`, `build.ssg`, `cache`, supported `dev` fields, `resolve.importMap`, `security`, `middleware.custom`, `fs.veryfront`, `fs.github`, AI primitive discovery, `client`, `styles.stylesheet`, `integrations`, `extensions`, and core `openapi` fields | +| CLI or diagnostics | `experimental.precompileMDX`, `generate.preferredRouter`, `ai.enabled`, and provider API-key checks | +| Accepted for extension compatibility, without built-in semantics | `title`, `description`, `directories.ai`, `theme.colors`, `build.outDir/trailingSlash/esbuild`, `dev.host/open/hmrPort`, `theming`, `assetPipeline`, tracing/metrics project config, `search`, `fs.local.baseDir`, `fs.memory`, provider defaults, `ai.work`, `ai.mcp`, and `openapi.mcp` | + +Keep public documentation aligned with this table. Implementing a +compatibility-only field requires an owned consumer and end-to-end tests; +removing one requires an explicit deprecation or breaking-change decision. + +### Stylesheet selection + +`styles.stylesheet` is the provider-neutral project-relative path to the +global stylesheet. When it is omitted, runtime and build paths look for the +conventional `globals.css`; if no project stylesheet is available, the +registered `CSSProcessor` supplies its own `defaultStylesheet`. + +The `styles` object accepts only `stylesheet`. The removed +`tailwind.stylesheet` path and provider-specific plugin, theme, and custom-CSS +config are rejected by the strict project schema. Compiler defaults, plugin +loading, and other vendor policy belong to the selected CSS extension rather +than Config or another core module. + +In shared proxy mode (`PROXY_MODE=1`), the runtime owns the filesystem backend +and requires `VERYFRONT_API_BASE_URL` to be a credential-free HTTP(S) base URL +without a query or fragment. Project configuration must not override `fs`; an +attempted override is rejected instead of producing a mixed backend +configuration. + +### Hosted configuration boundary + +Local filesystems, standalone deployments, and trusted single-project virtual +filesystems preserve executable TypeScript and JavaScript configuration. +Shared multi-project runtimes use a different boundary: they read the selected +config file once from an authenticated project/source context and evaluate its +declarative subset in a bounded worker. + +The hosted evaluator accepts static data plus the supported `veryfront` config +helpers. It rejects imports other than those helpers, host globals, filesystem +or network access, dynamic code, executable extensions and middleware, and +function-valued policies. The runtime validates project, source, release, and +environment identity before filesystem access. `getEnv` receives only the +filtered tenant environment snapshot prepared for that same source. + +Hosted cache policy permits memory render controls but rejects `cache.dir`, +persistent or network render backends, and backend-specific targets before the +validated result reaches config merging. The render allowlist contains only +`type: "memory"`, `ttl`, `maxEntries`, and `public`; `maxEntries` cannot exceed +the production default of 500. Top-level cache families and bundle-manifest +controls are independently allowlisted, so future storage capabilities fail +closed until the hosted boundary explicitly reviews them. Trusted local and +standalone config retains the complete cache schema. + +Production environment sources are bound to their exact active release. +Preview sources are bound to the selected branch and are not persisted in the +production config cache. An exact release that has no authoritative environment +identity is evaluated with the `release` label and an empty, frozen environment +snapshot; it never inherits production secrets by convention. + +Hosted parse, policy, protocol, capacity, and timeout failures fail closed. +They are not retried by executing tenant configuration in the host process, and +operational file-read errors are not treated as a missing config file. ## Environment Config (`EnvironmentConfig`) -System-level configuration read from environment variables. Captured as a frozen snapshot at startup. +System-level configuration read from environment variables. Before environment +loading is marked complete, getters return fresh frozen snapshots so an early +read cannot permanently cache an incomplete environment. After loading, +initialization stores one frozen process-wide snapshot. ```typescript +// Internal source import; this alias is not a package subpath. import { getEnvironmentConfig } from "#veryfront/config/environment-config.ts"; const env = getEnvironmentConfig(); console.log(env.apiBaseUrl); // from VERYFRONT_API_BASE_URL -console.log(env.debug); // from DEBUG +console.log(env.debug); // from VERYFRONT_DEBUG ``` **Key properties:** @@ -45,21 +121,36 @@ console.log(env.debug); // from DEBUG - API: `apiBaseUrl`, `apiToken`, `projectSlug` - Observability: `otelEnabled`, `otelEndpoint`, `otelServiceName` - AI keys: `openaiApiKey`, `anthropicApiKey`, `googleApiKey` -- Network: `port`, `requestTimeoutMs`, `redisUrl` +- Network: `port`, `requestTimeoutMs` ## Runtime Config (`RuntimeConfig`) -The merged configuration used at runtime. Combines project config with environment overrides and adds runtime info. +An opt-in, process-local helper that combines configuration supplied by its +caller with an environment snapshot and adds runtime flags. Server bootstrap +and hosted config loading do **not** automatically publish +`veryfront.config.*` values to this singleton. + +Use `createRuntimeConfig(projectConfig, env)` when you need a standalone value. +`initRuntimeConfig(projectConfig)` and `updateRuntimeConfig(projectConfig)` are +only for trusted single-tenant startup or tooling. Never put request-scoped +hosted tenant configuration in the singleton. ```typescript -import { getRuntimeConfig } from "#veryfront/config"; +// Internal source aliases are shown because this README documents the module. +import { createRuntimeConfig, getRuntimeConfig, initRuntimeConfig } from "#veryfront/config"; + +const standalone = createRuntimeConfig({ router: "pages" }); +console.log(standalone.router); // "pages" -const config = getRuntimeConfig(); -console.log(config.build.target); // from VeryfrontConfig -console.log(config.runtime.isDevelopment); // computed from env -console.log(config.runtime.env.apiToken); // from EnvironmentConfig +initRuntimeConfig({ title: "Trusted single-tenant process" }); +const processConfig = getRuntimeConfig(); +console.log(processConfig.runtime.isDevelopment); // computed from host env ``` +Calling `getRuntimeConfig()` before explicit initialization lazily creates a +defaults-plus-host-environment singleton. It does not discover or load a +project config file. + **Structure:** ```typescript @@ -83,10 +174,16 @@ src/config/ ├── environment-config.ts # EnvironmentConfig type and getters ├── runtime-config.ts # RuntimeConfig merging logic ├── loader.ts # Config file loading and caching +├── config-files.ts # Canonical filenames and discovery order +├── config-shim.ts # Cross-runtime config helper module +├── declarative-evaluator.ts # Hosted declarative parser/evaluator +├── declarative-evaluator-worker-*.ts +│ # Bounded worker protocol and lifecycle +├── snapshot.ts # Descriptor-safe immutable snapshots ├── define-config.ts # defineConfig() helper ├── defaults.ts # Default values ├── network-defaults.ts # Network-related defaults -├── schemas/ # Zod schemas for validation +├── schemas/ # Runtime schemas for validation │ └── index.ts ├── env.ts # Environment accessor helpers └── *.test.ts # Tests @@ -94,14 +191,22 @@ src/config/ ## Usage Patterns -### Reading config in application code +### Loading project config at an owning boundary ```typescript -import { getRuntimeConfig } from "#veryfront/config"; +import { getConfig } from "#veryfront/config"; +import { runtime } from "#veryfront/platform"; -const config = getRuntimeConfig(); +const config = await getConfig(projectDir, await runtime.get()); ``` +These `#veryfront/*` aliases are internal source boundaries. The published +package intentionally does not export `veryfront/config` or +`veryfront/platform`. + +Do not substitute the process-wide `RuntimeConfig` singleton for +request-scoped hosted project state. + ### Reading environment values ```typescript @@ -128,3 +233,6 @@ it("test with custom env", () => { // use env in test }); ``` + +The underscored reset helper and `#veryfront/*` aliases are internal test +surfaces, not package APIs. diff --git a/src/config/config-files.test.ts b/src/config/config-files.test.ts new file mode 100644 index 0000000000..fc968b3c61 --- /dev/null +++ b/src/config/config-files.test.ts @@ -0,0 +1,70 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { findVeryfrontConfigFile, VERYFRONT_CONFIG_FILES } from "./config-files.ts"; + +describe("config-files", () => { + it("keeps the recognized config filename list immutable at runtime", () => { + assertEquals(Object.isFrozen(VERYFRONT_CONFIG_FILES), true); + assertEquals(VERYFRONT_CONFIG_FILES, [ + "veryfront.config.js", + "veryfront.config.ts", + "veryfront.config.mjs", + ]); + }); + + it("finds an MJS-only config after checking higher-precedence names", async () => { + const projectDir = "/project"; + const existing = new Set([join(projectDir, "veryfront.config.mjs")]); + const checked: string[] = []; + + const configFile = await findVeryfrontConfigFile(projectDir, (path) => { + checked.push(path); + return existing.has(path); + }); + + assertEquals(configFile, { + fileName: "veryfront.config.mjs", + path: join(projectDir, "veryfront.config.mjs"), + }); + assertEquals( + checked, + VERYFRONT_CONFIG_FILES.map((fileName) => join(projectDir, fileName)), + ); + }); + + it("selects JavaScript before TypeScript and MJS when multiple files exist", async () => { + const projectDir = "/project"; + const checked: string[] = []; + + const configFile = await findVeryfrontConfigFile(projectDir, (path) => { + checked.push(path); + return true; + }); + + assertEquals(configFile, { + fileName: "veryfront.config.js", + path: join(projectDir, "veryfront.config.js"), + }); + assertEquals(checked, [join(projectDir, "veryfront.config.js")]); + }); + + it("preserves UNC project identity in every discovery candidate", async () => { + for (const projectDir of ["//server/share/project", "\\\\server\\share\\project"]) { + const checked: string[] = []; + + await findVeryfrontConfigFile(projectDir, (path) => { + checked.push(path); + return false; + }); + + assertEquals( + checked, + VERYFRONT_CONFIG_FILES.map( + (fileName) => `//server/share/project/${fileName}`, + ), + ); + } + }); +}); diff --git a/src/config/config-files.ts b/src/config/config-files.ts index a5f7e13079..a1e57f7d81 100644 --- a/src/config/config-files.ts +++ b/src/config/config-files.ts @@ -1,6 +1,52 @@ +import { join } from "#veryfront/compat/path/index.ts"; + /** Config filenames recognized by the Veryfront project loader. */ -export const VERYFRONT_CONFIG_FILES = [ - "veryfront.config.js", - "veryfront.config.ts", - "veryfront.config.mjs", -] as const; +export const VERYFRONT_CONFIG_FILES = Object.freeze( + [ + "veryfront.config.js", + "veryfront.config.ts", + "veryfront.config.mjs", + ] as const, +); + +export type VeryfrontConfigFileName = (typeof VERYFRONT_CONFIG_FILES)[number]; + +export interface VeryfrontConfigFile { + fileName: VeryfrontConfigFileName; + path: string; +} + +export type ConfigFileExists = (path: string) => boolean | Promise; + +function joinConfigFilePath( + projectDir: string, + fileName: VeryfrontConfigFileName, +): string { + const normalizedProjectDir = projectDir.replaceAll("\\", "/"); + if (/^\/\/[^/]+\/+[^/]+(?:\/|$)/.test(normalizedProjectDir)) { + // The general path facade follows host POSIX semantics and therefore + // collapses a leading double slash. At this configuration boundary both + // accepted UNC spellings identify the same remote share, so preserve that + // namespace explicitly while still normalizing the remaining segments. + return `//${join(normalizedProjectDir.slice(2), fileName)}`; + } + return join(projectDir, fileName); +} + +/** + * Find the first project config file using the loader's canonical precedence. + * + * Filesystem errors are intentionally left to the caller so each boundary can + * preserve its own required or best-effort behavior. + */ +export async function findVeryfrontConfigFile( + projectDir: string, + exists: ConfigFileExists, +): Promise { + for (const fileName of VERYFRONT_CONFIG_FILES) { + const path = joinConfigFilePath(projectDir, fileName); + if (await exists(path)) return { fileName, path }; + } + + return null; +} diff --git a/src/config/config-shim.test.ts b/src/config/config-shim.test.ts new file mode 100644 index 0000000000..c07aecf4f9 --- /dev/null +++ b/src/config/config-shim.test.ts @@ -0,0 +1,59 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createConfigShimModule, + encodeConfigShimSource, + VERYFRONT_CONFIG_SHIM_SOURCE, + VERYFRONT_CONFIG_SHIM_URL, +} from "./config-shim.ts"; +import { defineConfig, defineConfigWithEnv, mergeConfigs } from "./define-config.ts"; +import { getEnv } from "#veryfront/platform/compat/process.ts"; + +describe("config shim", () => { + it("can be materialized more than once without bridge collisions", async () => { + const first = await import(`./config-shim.ts?materialization=${crypto.randomUUID()}`); + const second = await import(`./config-shim.ts?materialization=${crypto.randomUUID()}`); + + assert(first.VERYFRONT_CONFIG_SHIM_URL !== second.VERYFRONT_CONFIG_SHIM_URL); + }); + + it("matches the documented named root config exports", async () => { + const module = await import(VERYFRONT_CONFIG_SHIM_URL); + + assertEquals(typeof module.defineConfig, "function"); + assertEquals(typeof module.defineConfigWithEnv, "function"); + assertEquals(typeof module.getEnv, "function"); + assertEquals(typeof module.mergeConfigs, "function"); + assertEquals(module.default, undefined); + assertEquals("source" in module, false); + assertEquals("url" in module, false); + assert(!VERYFRONT_CONFIG_SHIM_URL.includes(VERYFRONT_CONFIG_SHIM_SOURCE)); + assert(!VERYFRONT_CONFIG_SHIM_URL.includes("__veryfrontConfigShimBridge")); + }); + + it("encodes UTF-8 source as deterministic executable base64", async () => { + const source = 'export const label = "räksmörgås 🦊";'; + const encoded = encodeConfigShimSource(source); + const decodedBytes = Uint8Array.from( + atob(encoded), + (character) => character.charCodeAt(0), + ); + const module = await import(`data:text/javascript;base64,${encoded}`); + + assertEquals(new TextDecoder().decode(decodedBytes), source); + assertEquals(module.label, "räksmörgås 🦊"); + }); + + it("rejects names that cannot form an isolated bridge identifier", () => { + const bridge = { defineConfig, defineConfigWithEnv, getEnv, mergeConfigs }; + + for (const name of ["", "Loader", "loader name", "loader:name", "../loader"]) { + assertThrows( + () => createConfigShimModule(name, bridge), + TypeError, + `Invalid config shim name "${name}"`, + ); + } + }); +}); diff --git a/src/config/config-shim.ts b/src/config/config-shim.ts new file mode 100644 index 0000000000..8b121a5dac --- /dev/null +++ b/src/config/config-shim.ts @@ -0,0 +1,87 @@ +import { defineConfig, defineConfigWithEnv, mergeConfigs } from "./define-config.ts"; +import { getEnv } from "#veryfront/platform/compat/process.ts"; + +export type ConfigShimBridge = Readonly<{ + defineConfig: typeof defineConfig; + defineConfigWithEnv: typeof defineConfigWithEnv; + getEnv: typeof getEnv; + mergeConfigs: typeof mergeConfigs; +}>; + +export type ConfigShimModule = Readonly<{ + source: string; + url: string; +}>; + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** + * Encode JavaScript source as deterministic UTF-8 base64 without relying on + * Node-specific globals. + * + * @internal + */ +export function encodeConfigShimSource(source: string): string { + const bytes = new TextEncoder().encode(source); + let encoded = ""; + + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const hasSecond = index + 1 < bytes.length; + const hasThird = index + 2 < bytes.length; + const second = hasSecond ? bytes[index + 1] ?? 0 : 0; + const third = hasThird ? bytes[index + 2] ?? 0 : 0; + + encoded += BASE64_ALPHABET.charAt(first >> 2); + encoded += BASE64_ALPHABET.charAt(((first & 0x03) << 4) | (second >> 4)); + encoded += hasSecond ? BASE64_ALPHABET.charAt(((second & 0x0f) << 2) | (third >> 6)) : "="; + encoded += hasThird ? BASE64_ALPHABET.charAt(third & 0x3f) : "="; + } + + return encoded; +} + +export function createConfigShimModule( + name: string, + bridge: ConfigShimBridge, +): ConfigShimModule { + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new TypeError(`Invalid config shim name "${name}"`); + } + + const bridgeKey = `__veryfrontConfigShimBridgeV1:${name}:${crypto.randomUUID()}`; + const frozenBridge = Object.freeze({ ...bridge }); + Object.defineProperty(globalThis, bridgeKey, { + configurable: false, + enumerable: false, + writable: false, + value: frozenBridge, + }); + + const source = [ + `const bridge = globalThis[${JSON.stringify(bridgeKey)}];`, + 'if (!bridge) throw new Error("Veryfront config helper bridge is unavailable");', + "export const defineConfig = bridge.defineConfig;", + "export const defineConfigWithEnv = bridge.defineConfigWithEnv;", + "export const getEnv = bridge.getEnv;", + "export const mergeConfigs = bridge.mergeConfigs;", + ].join("\n"); + + return Object.freeze({ + source, + url: `data:text/javascript;base64,${encodeConfigShimSource(source)}`, + }); +} + +const defaultConfigShim = createConfigShimModule("loader", { + defineConfig, + defineConfigWithEnv, + getEnv, + mergeConfigs, +}); + +/** Source for the bare `veryfront` module used while evaluating project config. */ +export const VERYFRONT_CONFIG_SHIM_SOURCE = defaultConfigShim.source; + +/** Data URL form used by temp-file config imports. */ +export const VERYFRONT_CONFIG_SHIM_URL = defaultConfigShim.url; diff --git a/src/config/coverage-ci.test.ts b/src/config/coverage-ci.test.ts deleted file mode 100644 index 2a470ac66c..0000000000 --- a/src/config/coverage-ci.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { assertEquals, assertStringIncludes, assertThrows } from "#std/assert"; -import { - buildCoverageCommandArgs, - buildDenoTestCommandArgs, - isUnitCoverageTestFile, - mergeLcovReports, - parseShardSpec, - selectShardFiles, -} from "../../scripts/test/coverage-ci.ts"; - -Deno.test("parseShardSpec accepts one-based shard coordinates", () => { - assertEquals(parseShardSpec("3/8"), { index: 3, total: 8 }); -}); - -Deno.test("parseShardSpec rejects out-of-range shard coordinates", () => { - assertThrows( - () => parseShardSpec("0/8"), - Error, - "Invalid shard spec", - ); - assertThrows( - () => parseShardSpec("9/8"), - Error, - "Invalid shard spec", - ); -}); - -Deno.test("selectShardFiles splits files deterministically by sorted order", () => { - const files = [ - "src/d.test.ts", - "src/a.test.ts", - "src/c.test.ts", - "src/b.test.ts", - "src/e.test.ts", - ]; - - assertEquals(selectShardFiles(files, { index: 1, total: 2 }), [ - "src/a.test.ts", - "src/c.test.ts", - "src/e.test.ts", - ]); - assertEquals(selectShardFiles(files, { index: 2, total: 2 }), [ - "src/b.test.ts", - "src/d.test.ts", - ]); -}); - -Deno.test("unit coverage shards include TypeScript and TSX unit tests only", () => { - assertEquals(isUnitCoverageTestFile("src/cache/backend.test.ts"), true); - assertEquals(isUnitCoverageTestFile("src/react/app-shell.test.tsx"), true); - assertEquals(isUnitCoverageTestFile("src/cache/backend.integration.test.ts"), false); - assertEquals(isUnitCoverageTestFile("src/react/app.integration.test.tsx"), false); - assertEquals(isUnitCoverageTestFile("src/workflow/__tests__/legacy.test.ts"), false); - assertEquals(isUnitCoverageTestFile("src/cache/backend.ts"), false); -}); - -Deno.test("buildDenoTestCommandArgs keeps coverage profiles isolated per shard", () => { - const args = buildDenoTestCommandArgs({ - coverageDir: "coverage-shard-3", - files: ["src/example.test.ts"], - }); - - assertEquals(args.includes("--coverage=coverage-shard-3"), true); - assertEquals(args.includes("--coverage-raw-data-only"), true); - assertEquals(args.includes("--parallel"), true); - assertEquals(args.includes("--fail-fast"), false); - assertEquals(args.includes("src/example.test.ts"), true); -}); - -Deno.test("buildCoverageCommandArgs converts a shard profile dir to an lcov stream", () => { - const args = buildCoverageCommandArgs([ - "coverage-shard-1", - ]); - - assertStringIncludes(args.join(" "), "coverage-shard-1"); - assertEquals(args.includes("--lcov"), true); - assertEquals(args.includes("--include=src/"), true); -}); - -Deno.test("mergeLcovReports combines line hits from all shard lcov files", () => { - const merged = mergeLcovReports([ - [ - "SF:src/shared.ts", - "DA:1,1", - "DA:2,0", - "LH:1", - "LF:2", - "end_of_record", - ].join("\n"), - [ - "SF:src/shared.ts", - "DA:1,0", - "DA:2,3", - "LH:1", - "LF:2", - "end_of_record", - ].join("\n"), - ]); - - assertStringIncludes(merged, "SF:src/shared.ts"); - assertStringIncludes(merged, "DA:1,1"); - assertStringIncludes(merged, "DA:2,3"); - assertStringIncludes(merged, "LH:2"); - assertStringIncludes(merged, "LF:2"); -}); diff --git a/src/config/declarative-evaluator-worker-entry.ts b/src/config/declarative-evaluator-worker-entry.ts new file mode 100644 index 0000000000..8101214171 --- /dev/null +++ b/src/config/declarative-evaluator-worker-entry.ts @@ -0,0 +1,173 @@ +/** + * One-shot worker entry for hosted declarative configuration evaluation. + * + * The parser is part of the worker's initial module graph, so a Deno worker + * created with `permissions: "none"` never needs a late dynamic parser import. + * Only validated protocol DTOs cross this boundary; evaluation errors are + * serialized without their messages, stacks, or source text. + * + * @module + */ + +import { BabelParseOnlyParser } from "@veryfront/ext-parser-babel/parser-only"; +import { evaluateDeclarativeConfigWithParser } from "./declarative-evaluator.ts"; +import { + createDeclarativeConfigWorkerErrorResponse, + createDeclarativeConfigWorkerSuccessResponse, + decodeDeclarativeConfigWorkerRequest, +} from "./declarative-evaluator-worker-protocol.ts"; + +interface WorkerEntryTransport { + postMessage(value: unknown): void; + close(): void; +} + +interface WebWorkerScope { + addEventListener( + type: "message" | "messageerror", + listener: (event: MessageEvent) => void, + ): void; + removeEventListener( + type: "message" | "messageerror", + listener: (event: MessageEvent) => void, + ): void; + postMessage(value: unknown): void; + close(): void; +} + +async function evaluateRequest(value: unknown): Promise { + try { + const request = decodeDeclarativeConfigWorkerRequest(value); + const snapshot = await evaluateDeclarativeConfigWithParser( + request.evaluationOptions, + new BabelParseOnlyParser(), + ); + return createDeclarativeConfigWorkerSuccessResponse(snapshot); + } catch (error) { + return createDeclarativeConfigWorkerErrorResponse(error); + } +} + +function closeSafely(transport: WorkerEntryTransport): void { + try { + transport.close(); + } catch { + // The result has already been sent, so there is no second safe response. + } +} + +function processRequest(value: unknown, transport: WorkerEntryTransport): void { + void evaluateRequest(value).then( + (response) => { + try { + transport.postMessage(response); + } catch { + // A broken transport cannot accept a second response attempt. + } finally { + closeSafely(transport); + } + }, + (error) => { + // The protocol serializer is expected to be total, but retain the same + // redacted boundary if an unexpected promise rejection escapes. + try { + transport.postMessage( + createDeclarativeConfigWorkerErrorResponse(error), + ); + } catch { + // A broken transport cannot accept a second response attempt. + } finally { + closeSafely(transport); + } + }, + ).catch(() => closeSafely(transport)); +} + +function isWebWorkerScope(value: unknown): value is WebWorkerScope { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return typeof candidate.addEventListener === "function" && + typeof candidate.removeEventListener === "function" && + typeof candidate.postMessage === "function" && + typeof candidate.close === "function"; +} + +function installWebWorkerTransport(scope: WebWorkerScope): void { + let claimed = false; + + const removeListeners = (): void => { + scope.removeEventListener("message", onMessage); + scope.removeEventListener("messageerror", onMessageError); + }; + const claim = (): boolean => { + if (claimed) return false; + claimed = true; + removeListeners(); + return true; + }; + const transport: WorkerEntryTransport = { + postMessage(value) { + scope.postMessage(value); + }, + close() { + scope.close(); + }, + }; + const onMessage = (event: MessageEvent): void => { + if (!claim()) return; + processRequest(event.data, transport); + }; + const onMessageError = (_event: MessageEvent): void => { + if (!claim()) return; + processRequest(undefined, transport); + }; + + scope.addEventListener("message", onMessage); + scope.addEventListener("messageerror", onMessageError); +} + +async function installNodeWorkerTransport(): Promise { + const { parentPort } = await import("node:worker_threads"); + if (parentPort === null) { + throw new TypeError( + "Declarative evaluator worker entry requires a worker transport", + ); + } + + let claimed = false; + const removeListeners = (): void => { + parentPort.off("message", onMessage); + parentPort.off("messageerror", onMessageError); + }; + const claim = (): boolean => { + if (claimed) return false; + claimed = true; + removeListeners(); + return true; + }; + const transport: WorkerEntryTransport = { + postMessage(value) { + parentPort.postMessage(value); + }, + close() { + parentPort.close(); + }, + }; + const onMessage = (value: unknown): void => { + if (!claim()) return; + processRequest(value, transport); + }; + const onMessageError = (_error: unknown): void => { + if (!claim()) return; + processRequest(undefined, transport); + }; + + parentPort.once("message", onMessage); + parentPort.once("messageerror", onMessageError); +} + +if (isWebWorkerScope(globalThis)) { + installWebWorkerTransport(globalThis); +} else { + await installNodeWorkerTransport(); +} diff --git a/src/config/declarative-evaluator-worker-protocol.ts b/src/config/declarative-evaluator-worker-protocol.ts new file mode 100644 index 0000000000..8f4d22a9d1 --- /dev/null +++ b/src/config/declarative-evaluator-worker-protocol.ts @@ -0,0 +1,907 @@ +/** + * Plain-data protocol for the isolated declarative configuration evaluator. + * + * Neither side trusts structured clone to preserve prototypes, descriptors, or + * frozen state. Every received envelope is inspected through own property + * descriptors, rebuilt with a null prototype, and frozen before use. + * + * @module + */ + +import { + DECLARATIVE_CONFIG_FILE_NAME, + DECLARATIVE_CONFIG_POLICY_VERSION, + type DeclarativeConfigErrorCode, + type DeclarativeConfigErrorPhase, + type DeclarativeConfigErrorReason, + DeclarativeConfigEvaluationError, + type DeclarativeConfigFileName, + type DeclarativeConfigSourceLocation, + isDeclarativeConfigFileName, + type PreparedDeclarativeConfigWorkerPayload, +} from "./declarative-evaluator.ts"; +import { canonicalizeConfigSnapshot, type ConfigSnapshotRecord } from "./snapshot.ts"; + +const ArrayIsArray = Array.isArray; +const NumberIsSafeInteger = Number.isSafeInteger; +const ObjectCreate = Object.create; +const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectPrototype = Object.prototype; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectOwnKeys = Reflect.ownKeys; +const StringPrototypeCharCodeAt = String.prototype.charCodeAt; + +const CACHE_FINGERPRINT_PREFIX = "ctx1:"; +const CACHE_FINGERPRINT_DIGEST_LENGTH = 64; + +/** Version of the worker request/response envelope contract. */ +export const DECLARATIVE_CONFIG_WORKER_PROTOCOL_VERSION = 2; + +/** The already-coupled cache identity and evaluator input sent to the worker. */ +export type DeclarativeConfigWorkerRequest = PreparedDeclarativeConfigWorkerPayload; + +/** Serializable fields retained from a typed evaluator failure. */ +export interface DeclarativeConfigWorkerErrorDTO { + readonly code: DeclarativeConfigErrorCode; + readonly phase: DeclarativeConfigErrorPhase; + readonly reason: DeclarativeConfigErrorReason; + readonly location: DeclarativeConfigSourceLocation | null; + readonly retryable: boolean; +} + +/** Successful worker response. */ +export interface DeclarativeConfigWorkerSuccessResponse { + readonly ok: true; + readonly snapshot: ConfigSnapshotRecord; +} + +/** Failed worker response. */ +export interface DeclarativeConfigWorkerErrorResponse { + readonly ok: false; + readonly error: DeclarativeConfigWorkerErrorDTO; +} + +/** Exact one-shot response emitted by the evaluator worker. */ +export type DeclarativeConfigWorkerResponse = + | DeclarativeConfigWorkerSuccessResponse + | DeclarativeConfigWorkerErrorResponse; + +/** Infrastructure failures that can be created outside the evaluator. */ +export type DeclarativeConfigWorkerInfrastructureReason = + | "worker-aborted" + | "worker-overloaded" + | "worker-protocol" + | "worker-timeout" + | "worker-unavailable"; + +type CapturedRecord = Readonly>; + +const ERROR_CODE_TABLE = ObjectFreeze( + { + "evaluation-type-error": true, + "evaluator-unavailable": true, + "forbidden-capability": true, + "input-invalid": true, + "invalid-binding": true, + "invalid-helper-usage": true, + "invalid-result": true, + "non-finite-number": true, + "parser-contract-violation": true, + "parser-unavailable": true, + "resource-limit-exceeded": true, + "source-too-large": true, + "syntax-error": true, + "unsupported-hosted-feature": true, + "unsupported-syntax": true, + } as const satisfies Readonly>, +); + +const ERROR_PHASE_TABLE = ObjectFreeze( + { + input: true, + parse: true, + validate: true, + evaluate: true, + result: true, + worker: true, + } as const satisfies Readonly>, +); + +const ERROR_REASON_TABLE = ObjectFreeze( + { + arguments: true, + "array-elements": true, + "ast-nodes": true, + "ast-shape": true, + "binding-count": true, + "config-file-name": true, + "crypto-unavailable": true, + "dangerous-key": true, + "duplicate-binding": true, + "duplicate-default-export": true, + "duplicate-key": true, + "environment-accessor": true, + "environment-bytes": true, + "environment-entries": true, + "environment-key": true, + "environment-name": true, + "environment-prototype": true, + "environment-symbol": true, + "environment-value": true, + "evaluation-depth": true, + "evaluation-steps": true, + "function-value": true, + "helper-arguments": true, + "helper-as-value": true, + "host-global": true, + "hosted-bundle-manifest-backend": true, + "hosted-cache-directory": true, + "hosted-cache-option": true, + "hosted-cors-origin": true, + "hosted-custom-middleware": true, + "hosted-extensions": true, + "hosted-render-cache-backend": true, + "hosted-render-cache-capacity": true, + "import-form": true, + "intermediate-string": true, + "missing-default-export": true, + "non-finite-result": true, + "object-key": true, + "object-properties": true, + "operand-type": true, + "options-accessor": true, + "options-prototype": true, + "parser-load": true, + "parser-shape": true, + "prepared-context": true, + "result-not-record": true, + "result-not-snapshot-safe": true, + "source-bytes": true, + "spread-copies": true, + "spread-operations": true, + "statement-count": true, + "syntax-error": true, + "template-expressions": true, + "unbound-identifier": true, + "unsupported-call": true, + "unsupported-export": true, + "unsupported-expression": true, + "unsupported-import": true, + "unsupported-statement": true, + "worker-aborted": true, + "worker-overloaded": true, + "worker-protocol": true, + "worker-timeout": true, + "worker-unavailable": true, + } as const satisfies Readonly>, +); + +function hasOwn(value: object, key: PropertyKey): boolean { + return ReflectApply(ObjectPrototypeHasOwnProperty, value, [key]) as boolean; +} + +function defineDataProperty( + target: object, + key: PropertyKey, + value: unknown, +): void { + const descriptor = ObjectCreate(null) as PropertyDescriptor; + descriptor.value = value; + descriptor.enumerable = true; + descriptor.configurable = false; + descriptor.writable = false; + ObjectDefineProperty(target, key, descriptor); +} + +function protocolError(): DeclarativeConfigEvaluationError { + return createDeclarativeConfigWorkerInfrastructureError("worker-protocol"); +} + +function failProtocol(): never { + throw protocolError(); +} + +function inspectPrototype(value: object): object | null { + try { + return ObjectGetPrototypeOf(value); + } catch { + return failProtocol(); + } +} + +function inspectIsArray(value: object): boolean { + try { + return ArrayIsArray(value); + } catch { + return failProtocol(); + } +} + +function inspectOwnKeys(value: object): PropertyKey[] { + try { + return ReflectOwnKeys(value); + } catch { + return failProtocol(); + } +} + +function inspectDescriptor( + value: object, + key: PropertyKey, +): PropertyDescriptor { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ObjectGetOwnPropertyDescriptor(value, key); + } catch { + return failProtocol(); + } + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !hasOwn(descriptor, "value") + ) { + return failProtocol(); + } + return descriptor; +} + +function isExpectedKey( + key: string, + expectedKeys: readonly string[], +): boolean { + for (let index = 0; index < expectedKeys.length; index += 1) { + if (key === expectedKeys[index]) return true; + } + return false; +} + +function captureExactRecord( + value: unknown, + expectedKeys: readonly string[], +): CapturedRecord { + if ( + typeof value !== "object" || + value === null || + inspectIsArray(value) + ) { + return failProtocol(); + } + + const prototype = inspectPrototype(value); + if (prototype !== null && prototype !== ObjectPrototype) { + return failProtocol(); + } + + const ownKeys = inspectOwnKeys(value); + if (ownKeys.length !== expectedKeys.length) return failProtocol(); + + const captured = ObjectCreate(null) as Record; + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + if (typeof key !== "string" || !isExpectedKey(key, expectedKeys)) { + return failProtocol(); + } + defineDataProperty(captured, key, inspectDescriptor(value, key).value); + } + return ObjectFreeze(captured); +} + +function captureStringMap(value: unknown): Readonly> { + if ( + typeof value !== "object" || + value === null || + inspectIsArray(value) + ) { + return failProtocol(); + } + + const prototype = inspectPrototype(value); + if (prototype !== null && prototype !== ObjectPrototype) { + return failProtocol(); + } + + const ownKeys = inspectOwnKeys(value); + const captured = ObjectCreate(null) as Record; + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + if (typeof key !== "string") return failProtocol(); + const entry = inspectDescriptor(value, key).value; + if (typeof entry !== "string") return failProtocol(); + defineDataProperty(captured, key, entry); + } + return ObjectFreeze(captured); +} + +function isKnownEnumValue(value: unknown, table: object): value is string { + if (typeof value !== "string") return false; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ObjectGetOwnPropertyDescriptor(table, value); + } catch { + return false; + } + return descriptor !== undefined && + hasOwn(descriptor, "value") && + descriptor.value === true; +} + +function isErrorCode(value: unknown): value is DeclarativeConfigErrorCode { + return isKnownEnumValue(value, ERROR_CODE_TABLE); +} + +function isErrorPhase(value: unknown): value is DeclarativeConfigErrorPhase { + return isKnownEnumValue(value, ERROR_PHASE_TABLE); +} + +function isErrorReason(value: unknown): value is DeclarativeConfigErrorReason { + return isKnownEnumValue(value, ERROR_REASON_TABLE); +} + +function isWorkerReason( + value: DeclarativeConfigErrorReason, +): value is DeclarativeConfigWorkerInfrastructureReason { + return value === "worker-aborted" || + value === "worker-overloaded" || + value === "worker-protocol" || + value === "worker-timeout" || + value === "worker-unavailable"; +} + +function isOneOf( + value: DeclarativeConfigErrorReason, + expected: readonly DeclarativeConfigErrorReason[], +): boolean { + for (let index = 0; index < expected.length; index += 1) { + if (value === expected[index]) return true; + } + return false; +} + +/** + * Preserve the evaluator error as a value object rather than trusting four + * independently valid enum members to form a meaningful classification. + */ +function isLegalErrorTuple( + code: DeclarativeConfigErrorCode, + phase: DeclarativeConfigErrorPhase, + reason: DeclarativeConfigErrorReason, + retryable: boolean, +): boolean { + if (code === "evaluator-unavailable") { + if (phase === "input") { + return reason === "crypto-unavailable" && retryable; + } + if (phase !== "worker" || !isWorkerReason(reason)) return false; + return retryable === ( + reason === "worker-overloaded" || + reason === "worker-timeout" || + reason === "worker-unavailable" + ); + } + if (code === "parser-unavailable") { + return phase === "parse" && reason === "parser-load" && retryable; + } + if (retryable || phase === "worker" || isWorkerReason(reason)) return false; + + switch (code) { + case "evaluation-type-error": + return phase === "evaluate" && reason === "operand-type"; + case "forbidden-capability": + return phase === "validate" && + isOneOf(reason, [ + "dangerous-key", + "host-global", + "unsupported-call", + ]); + case "input-invalid": + return phase === "input" && + isOneOf(reason, [ + "config-file-name", + "environment-accessor", + "environment-key", + "environment-name", + "environment-prototype", + "environment-symbol", + "environment-value", + "options-accessor", + "options-prototype", + "prepared-context", + "source-bytes", + ]); + case "invalid-binding": + return (phase === "evaluate" && reason === "unbound-identifier") || + (phase === "validate" && + isOneOf(reason, ["duplicate-binding", "unbound-identifier"])); + case "invalid-helper-usage": + return (phase === "evaluate" && reason === "helper-arguments") || + (phase === "validate" && + isOneOf(reason, ["helper-arguments", "helper-as-value"])); + case "invalid-result": + return (phase === "evaluate" && reason === "result-not-snapshot-safe") || + (phase === "result" && + isOneOf(reason, [ + "dangerous-key", + "result-not-record", + "result-not-snapshot-safe", + ])) || + (phase === "validate" && + isOneOf(reason, [ + "duplicate-default-export", + "duplicate-key", + "missing-default-export", + ])); + case "non-finite-number": + return (phase === "evaluate" || phase === "validate") && + reason === "non-finite-result"; + case "parser-contract-violation": + return (phase === "input" && reason === "parser-shape") || + (phase === "validate" && reason === "ast-shape"); + case "resource-limit-exceeded": + return (phase === "input" && + isOneOf(reason, ["environment-bytes", "environment-entries"])) || + (phase === "evaluate" && + isOneOf(reason, [ + "array-elements", + "evaluation-depth", + "evaluation-steps", + "intermediate-string", + "object-properties", + "spread-copies", + ])) || + (phase === "validate" && + isOneOf(reason, [ + "arguments", + "array-elements", + "ast-nodes", + "binding-count", + "evaluation-depth", + "object-key", + "object-properties", + "spread-operations", + "statement-count", + "template-expressions", + "unsupported-import", + ])); + case "source-too-large": + return phase === "input" && reason === "source-bytes"; + case "syntax-error": + return phase === "parse" && reason === "syntax-error"; + case "unsupported-hosted-feature": + return (phase === "validate" && reason === "function-value") || + (phase === "result" && + isOneOf(reason, [ + "hosted-bundle-manifest-backend", + "hosted-cache-directory", + "hosted-cache-option", + "hosted-cors-origin", + "hosted-custom-middleware", + "hosted-extensions", + "hosted-render-cache-backend", + "hosted-render-cache-capacity", + ])); + case "unsupported-syntax": + return (phase === "evaluate" && reason === "unsupported-expression") || + (phase === "validate" && + isOneOf(reason, [ + "array-elements", + "import-form", + "object-key", + "unsupported-call", + "unsupported-export", + "unsupported-expression", + "unsupported-import", + "unsupported-statement", + ])); + } + return false; +} + +function isCacheFingerprint(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length !== + CACHE_FINGERPRINT_PREFIX.length + CACHE_FINGERPRINT_DIGEST_LENGTH + ) { + return false; + } + + for (let index = 0; index < CACHE_FINGERPRINT_PREFIX.length; index += 1) { + if ( + (ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number) !== + (ReflectApply( + StringPrototypeCharCodeAt, + CACHE_FINGERPRINT_PREFIX, + [index], + ) as number) + ) { + return false; + } + } + + for ( + let index = CACHE_FINGERPRINT_PREFIX.length; + index < value.length; + index += 1 + ) { + const code = ReflectApply( + StringPrototypeCharCodeAt, + value, + [index], + ) as number; + const isDigit = code >= 48 && code <= 57; + const isLowerHex = code >= 97 && code <= 102; + if (!isDigit && !isLowerHex) return false; + } + return true; +} + +function captureLocation( + value: unknown, + maximumOffset = Number.MAX_SAFE_INTEGER, + expectedFileName?: DeclarativeConfigFileName, +): DeclarativeConfigSourceLocation | null { + if (value === null) return null; + + const captured = captureExactRecord(value, [ + "line", + "column", + "offset", + "fileName", + ]); + const line = captured.line; + const column = captured.column; + const offset = captured.offset; + if ( + !NumberIsSafeInteger(line) || + typeof line !== "number" || + line < 1 || + !NumberIsSafeInteger(column) || + typeof column !== "number" || + column < 0 || + !NumberIsSafeInteger(offset) || + typeof offset !== "number" || + offset < 0 || + offset > maximumOffset || + line > offset + 1 || + column > offset || + !isDeclarativeConfigFileName(captured.fileName) || + (expectedFileName !== undefined && captured.fileName !== expectedFileName) + ) { + return failProtocol(); + } + + const location = ObjectCreate(null) as { + line: number; + column: number; + offset: number; + fileName: DeclarativeConfigFileName; + }; + defineDataProperty(location, "line", line); + defineDataProperty(location, "column", column); + defineDataProperty(location, "offset", offset); + defineDataProperty( + location, + "fileName", + captured.fileName, + ); + return ObjectFreeze(location); +} + +function createErrorDTO( + code: DeclarativeConfigErrorCode, + phase: DeclarativeConfigErrorPhase, + reason: DeclarativeConfigErrorReason, + location: DeclarativeConfigSourceLocation | null, + retryable: boolean, +): DeclarativeConfigWorkerErrorDTO { + const dto = ObjectCreate(null) as { + code: DeclarativeConfigErrorCode; + phase: DeclarativeConfigErrorPhase; + reason: DeclarativeConfigErrorReason; + location: DeclarativeConfigSourceLocation | null; + retryable: boolean; + }; + defineDataProperty(dto, "code", code); + defineDataProperty(dto, "phase", phase); + defineDataProperty(dto, "reason", reason); + defineDataProperty(dto, "location", location); + defineDataProperty(dto, "retryable", retryable); + return ObjectFreeze(dto); +} + +function createUnavailableErrorDTO(): DeclarativeConfigWorkerErrorDTO { + return createErrorDTO( + "evaluator-unavailable", + "worker", + "worker-unavailable", + null, + true, + ); +} + +function captureEvaluationError( + error: DeclarativeConfigEvaluationError, +): DeclarativeConfigWorkerErrorDTO { + const code = inspectDescriptor(error, "code").value; + const phase = inspectDescriptor(error, "phase").value; + const reason = inspectDescriptor(error, "reason").value; + const location = inspectDescriptor(error, "location").value; + const retryable = inspectDescriptor(error, "retryable").value; + if ( + !isErrorCode(code) || + !isErrorPhase(phase) || + !isErrorReason(reason) || + typeof retryable !== "boolean" + ) { + return failProtocol(); + } + const capturedLocation = captureLocation(location); + if (!isLegalErrorTuple(code, phase, reason, retryable)) { + return failProtocol(); + } + return createErrorDTO( + code, + phase, + reason, + capturedLocation, + retryable, + ); +} + +/** + * Build a typed failure for worker lifecycle and protocol errors. + * + * The caller owns the retry policy because aborts and host availability have + * different retry semantics. + */ +export function createDeclarativeConfigWorkerInfrastructureError( + reason: DeclarativeConfigWorkerInfrastructureReason, +): DeclarativeConfigEvaluationError { + if (!isWorkerReason(reason)) { + throw new TypeError("Invalid declarative configuration worker failure"); + } + const retryable = reason !== "worker-aborted" && + reason !== "worker-protocol"; + return new DeclarativeConfigEvaluationError({ + code: "evaluator-unavailable", + phase: "worker", + reason, + location: null, + retryable, + }); +} + +/** + * Validate and detach one worker request. + * + * Resource and language semantics remain the evaluator's responsibility; this + * boundary only accepts the exact direct-input shape and plain string data. + */ +export function decodeDeclarativeConfigWorkerRequest( + value: unknown, +): DeclarativeConfigWorkerRequest { + const request = captureExactRecord(value, [ + "cacheFingerprint", + "policyVersion", + "evaluationOptions", + ]); + if ( + !isCacheFingerprint(request.cacheFingerprint) || + request.policyVersion !== DECLARATIVE_CONFIG_POLICY_VERSION + ) { + return failProtocol(); + } + + const evaluationOptions = captureExactRecord(request.evaluationOptions, [ + "source", + "fileName", + "environmentName", + "environment", + ]); + if ( + typeof evaluationOptions.source !== "string" || + !isDeclarativeConfigFileName(evaluationOptions.fileName) || + typeof evaluationOptions.environmentName !== "string" + ) { + return failProtocol(); + } + + const detachedOptions = ObjectCreate(null) as { + source: string; + fileName: DeclarativeConfigFileName; + environmentName: string; + environment: Readonly>; + }; + defineDataProperty(detachedOptions, "source", evaluationOptions.source); + defineDataProperty(detachedOptions, "fileName", evaluationOptions.fileName); + defineDataProperty( + detachedOptions, + "environmentName", + evaluationOptions.environmentName, + ); + defineDataProperty( + detachedOptions, + "environment", + captureStringMap(evaluationOptions.environment), + ); + ObjectFreeze(detachedOptions); + + const detachedRequest = ObjectCreate(null) as { + cacheFingerprint: string; + policyVersion: typeof DECLARATIVE_CONFIG_POLICY_VERSION; + evaluationOptions: typeof detachedOptions; + }; + defineDataProperty( + detachedRequest, + "cacheFingerprint", + request.cacheFingerprint, + ); + defineDataProperty( + detachedRequest, + "policyVersion", + DECLARATIVE_CONFIG_POLICY_VERSION, + ); + defineDataProperty( + detachedRequest, + "evaluationOptions", + detachedOptions, + ); + return ObjectFreeze(detachedRequest); +} + +/** Create the exact success envelope emitted by the worker. */ +export function createDeclarativeConfigWorkerSuccessResponse( + snapshot: unknown, +): DeclarativeConfigWorkerSuccessResponse { + let canonicalSnapshot: ReturnType; + try { + canonicalSnapshot = canonicalizeConfigSnapshot(snapshot); + } catch { + return failProtocol(); + } + if ( + typeof canonicalSnapshot !== "object" || + canonicalSnapshot === null || + ArrayIsArray(canonicalSnapshot) + ) { + return failProtocol(); + } + + const response = ObjectCreate(null) as { + ok: true; + snapshot: ConfigSnapshotRecord; + }; + defineDataProperty(response, "ok", true); + defineDataProperty( + response, + "snapshot", + canonicalSnapshot as ConfigSnapshotRecord, + ); + return ObjectFreeze(response); +} + +/** + * Create the exact failure envelope emitted by the worker. + * + * Unknown or malformed failures are deliberately collapsed to a retryable + * availability error; arbitrary names, messages, stacks, and prototypes never + * cross the boundary. + */ +export function createDeclarativeConfigWorkerErrorResponse( + error: unknown, +): DeclarativeConfigWorkerErrorResponse { + let dto: DeclarativeConfigWorkerErrorDTO; + if (error instanceof DeclarativeConfigEvaluationError) { + try { + dto = captureEvaluationError(error); + } catch { + dto = createUnavailableErrorDTO(); + } + } else { + dto = createUnavailableErrorDTO(); + } + + const response = ObjectCreate(null) as { + ok: false; + error: DeclarativeConfigWorkerErrorDTO; + }; + defineDataProperty(response, "ok", false); + defineDataProperty(response, "error", dto); + return ObjectFreeze(response); +} + +function decodeErrorDTO( + value: unknown, + maximumSourceOffset: number, + expectedFileName: DeclarativeConfigFileName, +): DeclarativeConfigWorkerErrorDTO { + const error = captureExactRecord(value, [ + "code", + "phase", + "reason", + "location", + "retryable", + ]); + if ( + !isErrorCode(error.code) || + !isErrorPhase(error.phase) || + !isErrorReason(error.reason) || + typeof error.retryable !== "boolean" + ) { + return failProtocol(); + } + + const location = captureLocation( + error.location, + maximumSourceOffset, + expectedFileName, + ); + if ( + !isLegalErrorTuple( + error.code, + error.phase, + error.reason, + error.retryable, + ) + ) { + return failProtocol(); + } + return createErrorDTO( + error.code, + error.phase, + error.reason, + location, + error.retryable, + ); +} + +/** + * Decode one worker response. + * + * A valid failure envelope is rethrown as a fresh local typed error. A success + * is recanonicalized so callers never observe structured-clone prototypes or + * mutable descriptors. + */ +export function decodeDeclarativeConfigWorkerResponse( + value: unknown, + maximumSourceOffset: number, + expectedFileName: DeclarativeConfigFileName = DECLARATIVE_CONFIG_FILE_NAME, +): DeclarativeConfigWorkerSuccessResponse { + if ( + !NumberIsSafeInteger(maximumSourceOffset) || + maximumSourceOffset < 0 || + !isDeclarativeConfigFileName(expectedFileName) + ) { + return failProtocol(); + } + if ( + typeof value !== "object" || + value === null || + inspectIsArray(value) + ) { + return failProtocol(); + } + const prototype = inspectPrototype(value); + if (prototype !== null && prototype !== ObjectPrototype) { + return failProtocol(); + } + + const ok = inspectDescriptor(value, "ok").value; + if (ok === true) { + const response = captureExactRecord(value, ["ok", "snapshot"]); + return createDeclarativeConfigWorkerSuccessResponse(response.snapshot); + } + + if (ok !== false) return failProtocol(); + const errorResponse = captureExactRecord(value, ["ok", "error"]); + const error = decodeErrorDTO( + errorResponse.error, + maximumSourceOffset, + expectedFileName, + ); + throw new DeclarativeConfigEvaluationError(error); +} diff --git a/src/config/declarative-evaluator-worker-runner.ts b/src/config/declarative-evaluator-worker-runner.ts new file mode 100644 index 0000000000..db4e87f9bb --- /dev/null +++ b/src/config/declarative-evaluator-worker-runner.ts @@ -0,0 +1,837 @@ +/** + * Killable worker boundary for hosted declarative configuration evaluation. + * + * Each request receives a fresh worker. A synchronous parser stall therefore + * cannot retain a pooled worker generation or block the host event loop. + * + * @module + */ + +import { isDeno, isNode } from "#veryfront/platform/compat/runtime.ts"; +import type { PreparedDeclarativeConfigWorkerPayload } from "./declarative-evaluator.ts"; +import type { ConfigSnapshotRecord } from "./snapshot.ts"; +import { + createDeclarativeConfigWorkerInfrastructureError, + decodeDeclarativeConfigWorkerResponse, +} from "./declarative-evaluator-worker-protocol.ts"; + +// Capture lifecycle-critical primordials before arbitrary trusted project code +// can mutate the shared host realm. Hosted configuration may run later in the +// same process; admission, cancellation, and cleanup must retain their original +// semantics even if ambient prototypes have been replaced. +const IntrinsicPromise = Promise; +const ArrayPrototypeIndexOf = Array.prototype.indexOf; +const ArrayPrototypePush = Array.prototype.push; +const ArrayPrototypeShift = Array.prototype.shift; +const ArrayPrototypeSplice = Array.prototype.splice; +const EventTargetPrototypeAddEventListener = EventTarget.prototype.addEventListener; +const EventTargetPrototypeRemoveEventListener = EventTarget.prototype.removeEventListener; +const MathCeil = Math.ceil; +const NumberIsSafeInteger = Number.isSafeInteger; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const PromisePrototypeThen = Promise.prototype.then; +const PromiseReject = Promise.reject; +const PromiseResolve = Promise.resolve; +const ReflectApply = Reflect.apply; +const abortSignalAbortedGetter = ObjectGetOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", +)?.get; +const scheduleTimeout = globalThis.setTimeout; +const cancelTimeout = globalThis.clearTimeout; + +if (typeof abortSignalAbortedGetter !== "function") { + throw new TypeError("AbortSignal intrinsics are unavailable"); +} +const intrinsicAbortSignalAbortedGetter = abortSignalAbortedGetter as () => boolean; + +function freezeObject(value: T): T { + return ReflectApply(ObjectFreeze, Object, [value]) as T; +} + +function isSignalAborted(signal: AbortSignal): boolean { + return ReflectApply(intrinsicAbortSignalAbortedGetter, signal, []) as boolean; +} + +function addAbortListener(signal: AbortSignal, listener: () => void): void { + ReflectApply(EventTargetPrototypeAddEventListener, signal, [ + "abort", + listener, + { once: true }, + ]); +} + +function removeAbortListener(signal: AbortSignal, listener: () => void): void { + ReflectApply(EventTargetPrototypeRemoveEventListener, signal, [ + "abort", + listener, + ]); +} + +function addEventTargetListener( + target: EventTarget, + type: string, + listener: EventListener, +): void { + ReflectApply(EventTargetPrototypeAddEventListener, target, [ + type, + listener, + ]); +} + +function removeEventTargetListener( + target: EventTarget, + type: string, + listener: EventListener, +): void { + ReflectApply(EventTargetPrototypeRemoveEventListener, target, [ + type, + listener, + ]); +} + +function arrayPush(array: T[], value: T): void { + ReflectApply(ArrayPrototypePush, array, [value]); +} + +function arrayShift(array: T[]): T | undefined { + return ReflectApply(ArrayPrototypeShift, array, []) as T | undefined; +} + +function arrayIndexOf(array: T[], value: T): number { + return ReflectApply(ArrayPrototypeIndexOf, array, [value]) as number; +} + +function arraySpliceOne(array: T[], index: number): void { + ReflectApply(ArrayPrototypeSplice, array, [index, 1]); +} + +function promiseResolve(value: T | PromiseLike): Promise> { + return ReflectApply(PromiseResolve, IntrinsicPromise, [value]) as Promise>; +} + +function promiseResolveVoid(): Promise { + return ReflectApply(PromiseResolve, IntrinsicPromise, []) as Promise; +} + +function promiseReject(error: unknown): Promise { + return ReflectApply(PromiseReject, IntrinsicPromise, [error]) as Promise; +} + +function thenPromise( + promise: Promise, + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, +): Promise { + return ReflectApply(PromisePrototypeThen, promise, [ + onFulfilled, + onRejected, + ]) as Promise; +} + +const DEFAULT_WORKER_TIMEOUT_MS = 5_000; +const MAX_WORKER_TIMEOUT_MS = 30_000; +const DEFAULT_WORKER_TERMINATION_DRAIN_TIMEOUT_MS = 1_000; +const MAX_WORKER_TERMINATION_DRAIN_TIMEOUT_MS = 5_000; +const NODE_WORKER_RESOURCE_LIMITS = freezeObject({ + maxOldGenerationSizeMb: 128, + maxYoungGenerationSizeMb: 32, + stackSizeMb: 4, +}); +const monotonicNow = globalThis.performance.now.bind(globalThis.performance); + +/** + * Process-wide admission limits for cold hosted configuration evaluations. + * + * Evaluation results are expected to be cached by their source/context + * identity. A deliberately small worker budget protects aggregate CPU and + * memory when many unique or uncached sources arrive together. + */ +export const DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS = freezeObject({ + maxActive: 2, + maxQueued: 16, +}); + +/** Host-controlled lifecycle policy for one worker evaluation. */ +export interface DeclarativeConfigWorkerRunnerOptions { + /** Wall-clock deadline covering worker startup, parsing, and evaluation. */ + readonly timeoutMs?: number; + /** Optional cancellation signal owned by the hosted caller. */ + readonly signal?: AbortSignal; +} + +interface DeclarativeConfigWorkerEndpointListeners { + readonly onMessage: (value: unknown) => void; + readonly onError: () => void; + readonly onMessageError: () => void; + readonly onExit?: (code: number) => void; +} + +interface DeclarativeConfigWorkerEndpoint { + postMessage(value: PreparedDeclarativeConfigWorkerPayload): void; + subscribe(listeners: DeclarativeConfigWorkerEndpointListeners): () => void; + terminate(): void | Promise; +} + +type DeclarativeConfigWorkerEndpointFactory = () => Promise< + DeclarativeConfigWorkerEndpoint +>; + +type WorkerOutcome = + | Readonly<{ kind: "resolve"; value: ConfigSnapshotRecord }> + | Readonly<{ kind: "reject"; error: unknown }>; + +interface WorkerEvaluationOperation { + /** Caller-visible result, which settles at the configured deadline. */ + readonly result: Promise; + /** + * Ordinary evaluation lifecycle. This drains after caller settlement and + * bounded termination of any endpoint that is already known. + */ + readonly drained: Promise; + /** + * Underlying endpoint-factory lifecycle. A never-settling factory remains + * counted here after ordinary capacity is released, so repeated calls cannot + * create an unbounded number of orphan startup attempts. + */ + readonly startupDrained: Promise; +} + +interface PendingAdmission { + settled: boolean; + readonly resolve: (release: () => void) => void; + readonly reject: (error: unknown) => void; + readonly signal: AbortSignal | undefined; + readonly onAbort: () => void; + timeout: ReturnType | undefined; +} + +class DeclarativeConfigWorkerAdmissionController { + readonly #maxActive: number; + readonly #maxQueued: number; + #active = 0; + readonly #queue: PendingAdmission[] = []; + + constructor(maxActive: number, maxQueued: number) { + if ( + !NumberIsSafeInteger(maxActive) || + maxActive < 1 || + !NumberIsSafeInteger(maxQueued) || + maxQueued < 0 + ) { + throw new TypeError( + "Declarative config worker admission limits must be non-negative safe integers with at least one active slot", + ); + } + this.#maxActive = maxActive; + this.#maxQueued = maxQueued; + } + + acquire(timeoutMs: number, signal?: AbortSignal): Promise<() => void> { + if (signal && isSignalAborted(signal)) { + return promiseReject( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + } + if (this.#active < this.#maxActive) { + this.#active += 1; + return promiseResolve(this.#createRelease()); + } + if (this.#queue.length >= this.#maxQueued) { + return promiseReject( + createDeclarativeConfigWorkerInfrastructureError("worker-overloaded"), + ); + } + + return new IntrinsicPromise<() => void>((resolve, reject) => { + const pending: PendingAdmission = { + settled: false, + resolve, + reject, + signal, + onAbort: () => { + this.#rejectQueued( + pending, + createDeclarativeConfigWorkerInfrastructureError( + "worker-aborted", + ), + ); + }, + timeout: undefined, + }; + pending.timeout = scheduleTimeout(() => { + this.#rejectQueued( + pending, + createDeclarativeConfigWorkerInfrastructureError("worker-timeout"), + ); + }, timeoutMs); + arrayPush(this.#queue, pending); + if (signal) { + addAbortListener(signal, pending.onAbort); + if (isSignalAborted(signal)) pending.onAbort(); + } + }); + } + + snapshot(): Readonly<{ active: number; queued: number }> { + return freezeObject({ + active: this.#active, + queued: this.#queue.length, + }); + } + + #createRelease(): () => void { + let released = false; + return () => { + if (released) return; + released = true; + this.#active -= 1; + this.#dispatch(); + }; + } + + #cleanupPending(pending: PendingAdmission): void { + if (pending.timeout !== undefined) { + cancelTimeout(pending.timeout); + pending.timeout = undefined; + } + if (pending.signal) removeAbortListener(pending.signal, pending.onAbort); + } + + #removeQueued(pending: PendingAdmission): void { + const index = arrayIndexOf(this.#queue, pending); + if (index !== -1) arraySpliceOne(this.#queue, index); + } + + #rejectQueued(pending: PendingAdmission, error: unknown): void { + if (pending.settled) return; + pending.settled = true; + this.#removeQueued(pending); + this.#cleanupPending(pending); + pending.reject(error); + } + + #dispatch(): void { + while (this.#active < this.#maxActive && this.#queue.length > 0) { + const pending = arrayShift(this.#queue); + if (!pending || pending.settled) continue; + pending.settled = true; + this.#cleanupPending(pending); + this.#active += 1; + pending.resolve(this.#createRelease()); + } + } +} + +class DeclarativeConfigWorkerStartupController { + readonly #maxPending: number; + #pending = 0; + + constructor(maxPending: number) { + if (!NumberIsSafeInteger(maxPending) || maxPending < 1) { + throw new TypeError( + "Declarative config worker startup limit must be a positive safe integer", + ); + } + this.#maxPending = maxPending; + } + + acquire(): () => void { + if (this.#pending >= this.#maxPending) { + throw createDeclarativeConfigWorkerInfrastructureError( + "worker-overloaded", + ); + } + this.#pending += 1; + + let released = false; + return () => { + if (released) return; + released = true; + this.#pending -= 1; + }; + } + + snapshot(): Readonly<{ pending: number; maxPending: number }> { + return freezeObject({ + pending: this.#pending, + maxPending: this.#maxPending, + }); + } +} + +const workerAdmissionController = new DeclarativeConfigWorkerAdmissionController( + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxActive, + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxQueued, +); +const workerStartupController = new DeclarativeConfigWorkerStartupController( + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxActive, +); + +function validateTimeoutMs(value: unknown): number { + if (value === undefined) return DEFAULT_WORKER_TIMEOUT_MS; + if ( + typeof value !== "number" || + !NumberIsSafeInteger(value) || + value < 1 || + value > MAX_WORKER_TIMEOUT_MS + ) { + throw new TypeError( + `Declarative config worker timeoutMs must be an integer from 1 to ${MAX_WORKER_TIMEOUT_MS}`, + ); + } + return value; +} + +function validateTerminationDrainTimeoutMs(value: unknown): number { + if ( + typeof value !== "number" || + !NumberIsSafeInteger(value) || + value < 1 || + value > MAX_WORKER_TERMINATION_DRAIN_TIMEOUT_MS + ) { + throw new TypeError( + `Declarative config worker termination drain timeout must be an integer from 1 to ${MAX_WORKER_TERMINATION_DRAIN_TIMEOUT_MS}`, + ); + } + return value; +} + +/** + * Invoke endpoint termination exactly once and contain every completion mode. + * + * Native worker implementations normally terminate promptly, but a runtime + * shim must not keep caller settlement or process-wide admission occupied + * forever. The rejection handler remains attached after the bounded drain + * completes, so a late failure cannot become an unhandled rejection. + */ +function drainEndpointTermination( + endpoint: DeclarativeConfigWorkerEndpoint, + timeoutMs: number, +): Promise { + let termination: Promise; + try { + termination = promiseResolve(endpoint.terminate()); + } catch { + return promiseResolveVoid(); + } + + return new IntrinsicPromise((resolve) => { + let settled = false; + let timeout: ReturnType | undefined; + const finish = (): void => { + if (settled) return; + settled = true; + if (timeout !== undefined) { + cancelTimeout(timeout); + timeout = undefined; + } + resolve(); + }; + + timeout = scheduleTimeout(finish, timeoutMs); + void thenPromise(termination, finish, finish); + }); +} + +function workerEntryUrl(): URL { + const extension = import.meta.url.endsWith(".ts") ? ".ts" : ".js"; + return new URL( + `./declarative-evaluator-worker-entry${extension}`, + import.meta.url, + ); +} + +function createDenoWorkerEndpoint(): DeclarativeConfigWorkerEndpoint { + type PermissionlessWorkerOptions = WorkerOptions & { + deno: { permissions: "none" }; + }; + + const options: PermissionlessWorkerOptions = { + type: "module", + deno: { permissions: "none" }, + }; + const worker = new Worker(workerEntryUrl(), options); + + return { + postMessage(value) { + worker.postMessage(value); + }, + subscribe(listeners) { + const onMessage = (event: MessageEvent) => { + listeners.onMessage(event.data); + }; + const onError = (event: ErrorEvent) => { + event.preventDefault(); + listeners.onError(); + }; + const onMessageError = () => { + listeners.onMessageError(); + }; + + addEventTargetListener(worker, "message", onMessage as EventListener); + addEventTargetListener(worker, "error", onError as EventListener); + addEventTargetListener(worker, "messageerror", onMessageError); + return () => { + removeEventTargetListener(worker, "message", onMessage as EventListener); + removeEventTargetListener(worker, "error", onError as EventListener); + removeEventTargetListener(worker, "messageerror", onMessageError); + }; + }, + terminate() { + worker.terminate(); + }, + }; +} + +async function createNodeWorkerEndpoint(): Promise< + DeclarativeConfigWorkerEndpoint +> { + const { Worker: NodeWorker } = await import("node:worker_threads"); + const worker = new NodeWorker(workerEntryUrl(), { + argv: [], + env: {}, + execArgv: [], + resourceLimits: NODE_WORKER_RESOURCE_LIMITS, + }); + + return { + postMessage(value) { + worker.postMessage(value); + }, + subscribe(listeners) { + const onMessage = (value: unknown) => listeners.onMessage(value); + const onError = () => listeners.onError(); + const onMessageError = () => listeners.onMessageError(); + const onExit = (code: number) => listeners.onExit?.(code); + + worker.on("message", onMessage); + worker.on("error", onError); + worker.on("messageerror", onMessageError); + worker.on("exit", onExit); + return () => { + worker.off("message", onMessage); + worker.off("error", onError); + worker.off("messageerror", onMessageError); + worker.off("exit", onExit); + }; + }, + terminate() { + return worker.terminate(); + }, + }; +} + +async function createRuntimeWorkerEndpoint(): Promise< + DeclarativeConfigWorkerEndpoint +> { + if (isDeno) return createDenoWorkerEndpoint(); + if (isNode) return await createNodeWorkerEndpoint(); + throw createDeclarativeConfigWorkerInfrastructureError("worker-unavailable"); +} + +function beginEvaluationWithEndpointFactory( + payload: PreparedDeclarativeConfigWorkerPayload, + options: DeclarativeConfigWorkerRunnerOptions, + endpointFactory: DeclarativeConfigWorkerEndpointFactory, + terminationDrainTimeoutMs = DEFAULT_WORKER_TERMINATION_DRAIN_TIMEOUT_MS, +): WorkerEvaluationOperation { + const timeoutMs = validateTimeoutMs(options.timeoutMs); + const validatedTerminationDrainTimeoutMs = validateTerminationDrainTimeoutMs( + terminationDrainTimeoutMs, + ); + const signal = options.signal; + if (signal && isSignalAborted(signal)) { + return { + result: promiseReject( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ), + drained: promiseResolveVoid(), + startupDrained: promiseResolveVoid(), + }; + } + + let resolveDrained: (() => void) | undefined; + const drained = new IntrinsicPromise((resolve) => { + resolveDrained = resolve; + }); + let resolveStartupDrained: (() => void) | undefined; + const startupDrained = new IntrinsicPromise((resolve) => { + resolveStartupDrained = resolve; + }); + let startupLifecycleDrained = false; + let endpointTerminated = true; + let lifecycleDrained = false; + + const result = new IntrinsicPromise((resolve, reject) => { + let endpoint: DeclarativeConfigWorkerEndpoint | undefined; + let unsubscribe: (() => void) | undefined; + let settled = false; + let terminationRequested = false; + let terminatedEndpoint: DeclarativeConfigWorkerEndpoint | undefined; + let endpointTermination: Promise | undefined; + + const drainLifecycleIfComplete = (): void => { + if (lifecycleDrained || !settled || !endpointTerminated) { + return; + } + lifecycleDrained = true; + resolveDrained?.(); + resolveDrained = undefined; + }; + + const drainStartupLifecycle = (): void => { + if (startupLifecycleDrained) return; + startupLifecycleDrained = true; + resolveStartupDrained?.(); + resolveStartupDrained = undefined; + }; + + const terminate = ( + candidate: DeclarativeConfigWorkerEndpoint | undefined = endpoint, + ): Promise => { + terminationRequested = true; + if (!candidate) { + drainLifecycleIfComplete(); + return promiseResolveVoid(); + } + if (terminatedEndpoint === candidate) { + return endpointTermination ?? promiseResolveVoid(); + } + terminatedEndpoint = candidate; + endpointTermination = drainEndpointTermination( + candidate, + validatedTerminationDrainTimeoutMs, + ); + void thenPromise(endpointTermination, () => { + if (terminatedEndpoint === candidate) { + endpointTerminated = true; + drainLifecycleIfComplete(); + } + }); + return endpointTermination; + }; + + const cleanup = () => { + cancelTimeout(timeout); + if (signal) removeAbortListener(signal, onAbort); + const release = unsubscribe; + unsubscribe = undefined; + try { + release?.(); + } catch { + // Listener cleanup is best-effort after the endpoint is terminal. + } + }; + + const settle = (outcome: WorkerOutcome): void => { + if (settled) return; + settled = true; + cleanup(); + void terminate(); + if (outcome.kind === "resolve") resolve(outcome.value); + else reject(outcome.error); + }; + + const rejectInfrastructure = ( + reason: + | "worker-aborted" + | "worker-protocol" + | "worker-timeout" + | "worker-unavailable", + ) => { + settle({ + kind: "reject", + error: createDeclarativeConfigWorkerInfrastructureError(reason), + }); + }; + + const onAbort = () => { + rejectInfrastructure("worker-aborted"); + }; + + const timeout = scheduleTimeout(() => { + rejectInfrastructure("worker-timeout"); + }, timeoutMs); + + if (signal) addAbortListener(signal, onAbort); + + void (async () => { + let createdEndpoint: DeclarativeConfigWorkerEndpoint; + try { + createdEndpoint = await endpointFactory(); + } catch { + drainStartupLifecycle(); + rejectInfrastructure("worker-unavailable"); + drainLifecycleIfComplete(); + return; + } + + endpoint = createdEndpoint; + endpointTerminated = false; + if (settled || terminationRequested) { + await terminate(createdEndpoint); + drainStartupLifecycle(); + return; + } + // The ordinary active slot now owns the live endpoint, so unresolved + // startup capacity can be returned before evaluation completes. + drainStartupLifecycle(); + + try { + const release = createdEndpoint.subscribe({ + onMessage(value) { + if (settled) return; + try { + const decoded = decodeDeclarativeConfigWorkerResponse( + value, + payload.evaluationOptions.source.length, + payload.evaluationOptions.fileName, + ); + settle({ kind: "resolve", value: decoded.snapshot }); + } catch (error) { + settle({ kind: "reject", error }); + } + }, + onError() { + rejectInfrastructure("worker-unavailable"); + }, + onMessageError() { + rejectInfrastructure("worker-protocol"); + }, + onExit() { + rejectInfrastructure("worker-unavailable"); + }, + }); + if (settled) { + try { + release(); + } catch { + // A synchronous terminal event won the subscription race. + } + return; + } + unsubscribe = release; + createdEndpoint.postMessage(payload); + } catch { + rejectInfrastructure("worker-unavailable"); + } + })(); + }); + + return { result, drained, startupDrained }; +} + +async function evaluateWithEndpointFactory( + payload: PreparedDeclarativeConfigWorkerPayload, + options: DeclarativeConfigWorkerRunnerOptions, + endpointFactory: DeclarativeConfigWorkerEndpointFactory, + terminationDrainTimeoutMs = DEFAULT_WORKER_TERMINATION_DRAIN_TIMEOUT_MS, +): Promise { + return await beginEvaluationWithEndpointFactory( + payload, + options, + endpointFactory, + terminationDrainTimeoutMs, + ).result; +} + +async function evaluateWithAdmissionController( + payload: PreparedDeclarativeConfigWorkerPayload, + options: DeclarativeConfigWorkerRunnerOptions, + endpointFactory: DeclarativeConfigWorkerEndpointFactory, + admissionController: DeclarativeConfigWorkerAdmissionController, + terminationDrainTimeoutMs = DEFAULT_WORKER_TERMINATION_DRAIN_TIMEOUT_MS, + startupController: DeclarativeConfigWorkerStartupController = workerStartupController, +): Promise { + const timeoutMs = validateTimeoutMs(options.timeoutMs); + const startedAt = monotonicNow(); + const release = await admissionController.acquire( + timeoutMs, + options.signal, + ); + let releaseStartup: (() => void) | undefined; + + let operation: WorkerEvaluationOperation; + try { + const remainingMs = MathCeil( + timeoutMs - (monotonicNow() - startedAt), + ); + if (remainingMs < 1) { + throw createDeclarativeConfigWorkerInfrastructureError( + "worker-timeout", + ); + } + releaseStartup = startupController.acquire(); + operation = beginEvaluationWithEndpointFactory( + payload, + { + signal: options.signal, + timeoutMs: remainingMs, + }, + endpointFactory, + terminationDrainTimeoutMs, + ); + } catch (error) { + releaseStartup?.(); + release(); + throw error; + } + + // Ordinary evaluation capacity is returned at the caller deadline even when + // endpoint creation never settles. The independent startup controller keeps + // that orphan attempt counted and rejects additional factory calls once its + // explicit bound is reached. A late factory result is terminated before its + // startup permit is returned. + void thenPromise(operation.drained, release); + void thenPromise(operation.startupDrained, releaseStartup); + return await operation.result; +} + +/** + * Evaluate one prepared hosted configuration in a fresh, bounded worker. + * + * The returned snapshot is decoded, recanonicalized, and deeply frozen by the + * host before it crosses back into trusted application code. + */ +export async function evaluatePreparedDeclarativeConfigInWorker( + payload: PreparedDeclarativeConfigWorkerPayload, + options: DeclarativeConfigWorkerRunnerOptions = {}, +): Promise { + return await evaluateWithAdmissionController( + payload, + options, + createRuntimeWorkerEndpoint, + workerAdmissionController, + ); +} + +/** @internal Test seam for deterministic lifecycle and protocol tests. */ +export const declarativeConfigWorkerRunnerInternals = freezeObject({ + createAdmissionController( + maxActive: number, + maxQueued: number, + ): DeclarativeConfigWorkerAdmissionController { + return new DeclarativeConfigWorkerAdmissionController( + maxActive, + maxQueued, + ); + }, + createStartupController( + maxPending: number, + ): DeclarativeConfigWorkerStartupController { + return new DeclarativeConfigWorkerStartupController(maxPending); + }, + getGlobalLifecycleState(): Readonly<{ + admission: Readonly<{ active: number; queued: number }>; + startup: Readonly<{ pending: number; maxPending: number }>; + }> { + return freezeObject({ + admission: workerAdmissionController.snapshot(), + startup: workerStartupController.snapshot(), + }); + }, + evaluateWithAdmissionController, + evaluateWithEndpointFactory, +}); diff --git a/src/config/declarative-evaluator-worker.test.ts b/src/config/declarative-evaluator-worker.test.ts new file mode 100644 index 0000000000..563440bfeb --- /dev/null +++ b/src/config/declarative-evaluator-worker.test.ts @@ -0,0 +1,1446 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { + createPreparedDeclarativeConfigWorkerPayload, + DeclarativeConfigEvaluationError, + type DeclarativeConfigFileName, + prepareDeclarativeConfigContext, +} from "./declarative-evaluator.ts"; +import { + createDeclarativeConfigWorkerErrorResponse, + createDeclarativeConfigWorkerSuccessResponse, + DECLARATIVE_CONFIG_WORKER_PROTOCOL_VERSION, + decodeDeclarativeConfigWorkerRequest, + decodeDeclarativeConfigWorkerResponse, +} from "./declarative-evaluator-worker-protocol.ts"; +import { + declarativeConfigWorkerRunnerInternals, + evaluatePreparedDeclarativeConfigInWorker, +} from "./declarative-evaluator-worker-runner.ts"; +import { MAX_HOSTED_RENDER_CACHE_ENTRIES } from "./defaults.ts"; + +const TestObjectCreate = Object.create; +const TestObjectDefineProperty = Object.defineProperty; +const TestObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const TestObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const TestReflectApply = Reflect.apply; +const TestReflectDeleteProperty = Reflect.deleteProperty; +const TestPromise = Promise; +const TestSetTimeout = globalThis.setTimeout.bind(globalThis); +const TestClearTimeout = globalThis.clearTimeout.bind(globalThis); +const TEST_STATE_WAIT_TIMEOUT_MS = 1_000; + +function testHasOwn(value: object, key: PropertyKey): boolean { + return TestReflectApply(TestObjectPrototypeHasOwnProperty, value, [ + key, + ]) as boolean; +} + +function copyPropertyDescriptorForTest( + descriptor: PropertyDescriptor, +): PropertyDescriptor { + const copied = TestObjectCreate(null) as PropertyDescriptor; + if (testHasOwn(descriptor, "configurable")) { + copied.configurable = descriptor.configurable; + } + if (testHasOwn(descriptor, "enumerable")) { + copied.enumerable = descriptor.enumerable; + } + if (testHasOwn(descriptor, "value")) copied.value = descriptor.value; + if (testHasOwn(descriptor, "writable")) { + copied.writable = descriptor.writable; + } + if (testHasOwn(descriptor, "get")) copied.get = descriptor.get; + if (testHasOwn(descriptor, "set")) copied.set = descriptor.set; + return copied; +} + +function replacePropertyForTest( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, +): () => void { + const previous = TestObjectGetOwnPropertyDescriptor(target, key); + const replacement = copyPropertyDescriptorForTest(descriptor); + replacement.configurable = true; + TestObjectDefineProperty(target, key, replacement); + return () => { + if (previous) { + TestObjectDefineProperty( + target, + key, + copyPropertyDescriptorForTest(previous), + ); + } else { + TestReflectDeleteProperty(target, key); + } + }; +} + +async function createPayload( + source: string, + fileName: DeclarativeConfigFileName = "veryfront.config.ts", +) { + const context = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { TENANT: "isolated" }, + }); + return createPreparedDeclarativeConfigWorkerPayload(source, context, fileName); +} + +function waitForCondition( + description: string, + snapshot: () => T, + matches: (current: T) => boolean, + timeoutMs = TEST_STATE_WAIT_TIMEOUT_MS, +): Promise { + return new TestPromise((resolve, reject) => { + let settled = false; + let pollTimer: ReturnType | undefined; + let deadlineTimer: ReturnType | undefined; + let lastObserved: T | undefined; + + const cleanup = (): void => { + if (pollTimer !== undefined) { + TestClearTimeout(pollTimer); + pollTimer = undefined; + } + if (deadlineTimer !== undefined) { + TestClearTimeout(deadlineTimer); + deadlineTimer = undefined; + } + }; + const resolveOnce = (current: T): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(current); + }; + const rejectOnce = (error: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const poll = (): void => { + try { + const current = snapshot(); + lastObserved = current; + if (matches(current)) { + resolveOnce(current); + return; + } + pollTimer = TestSetTimeout(poll, 0); + } catch (error) { + rejectOnce(error); + } + }; + + deadlineTimer = TestSetTimeout(() => { + let observed = lastObserved; + try { + observed = snapshot(); + } catch { + // Preserve the last successful sample in the timeout diagnostic. + } + rejectOnce( + new Error( + `Timed out after ${timeoutMs}ms waiting for ${description}; observed ${ + JSON.stringify(observed) + }`, + ), + ); + }, timeoutMs); + poll(); + }); +} + +async function waitForAdmissionState( + admission: { + snapshot(): Readonly<{ active: number; queued: number }>; + }, + expected: Readonly<{ active: number; queued: number }>, +): Promise { + await waitForCondition( + `admission state ${JSON.stringify(expected)}`, + () => admission.snapshot(), + (current) => + current.active === expected.active && + current.queued === expected.queued, + ); +} + +async function waitForStartupState( + startup: { + snapshot(): Readonly<{ pending: number; maxPending: number }>; + }, + expected: Readonly<{ pending: number; maxPending: number }>, +): Promise { + await waitForCondition( + `startup state ${JSON.stringify(expected)}`, + () => startup.snapshot(), + (current) => + current.pending === expected.pending && + current.maxPending === expected.maxPending, + ); +} + +Deno.test("declarative config worker returns a recanonicalized frozen snapshot", async () => { + const payload = await createPayload(` + import { getEnv } from "veryfront"; + export default { + title: getEnv("TENANT"), + nested: { enabled: true }, + list: ["a", "b"], + }; + `); + + const snapshot = await evaluatePreparedDeclarativeConfigInWorker(payload); + + assertEquals(snapshot, { + list: ["a", "b"], + nested: { enabled: true }, + title: "isolated", + }); + assertEquals(Object.getPrototypeOf(snapshot), null); + assertEquals(Object.getPrototypeOf(snapshot.nested), null); + assertEquals(Object.isFrozen(snapshot), true); + assertEquals(Object.isFrozen(snapshot.nested), true); + assertEquals(Object.isFrozen(snapshot.list), true); + assertThrows( + () => Object.defineProperty(snapshot, "title", { value: "mutated" }), + TypeError, + ); +}); + +Deno.test("declarative config worker rehydrates typed evaluation failures", async () => { + const payload = await createPayload( + "export default { secret: process.env.SECRET };", + ); + + const error = await assertRejects( + () => evaluatePreparedDeclarativeConfigInWorker(payload), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.name, "DeclarativeConfigEvaluationError"); + assertEquals(error.code, "forbidden-capability"); + assertEquals(error.phase, "validate"); + assertEquals(error.reason, "unsupported-call"); + assertEquals(error.retryable, false); + assertEquals(error.location?.fileName, "veryfront.config.ts"); +}); + +Deno.test("declarative config worker preserves hosted cache policy failures", async () => { + for ( + const [source, reason] of [ + [ + `export default { cache: { dir: ".tenant-cache" } };`, + "hosted-cache-directory", + ], + [ + `export default { + cache: { + render: { + type: "distributed", + }, + }, + };`, + "hosted-render-cache-backend", + ], + [ + `export default { + cache: { + render: { maxEntries: ${MAX_HOSTED_RENDER_CACHE_ENTRIES + 1} }, + }, + };`, + "hosted-render-cache-capacity", + ], + [ + `export default { + cache: { bundleManifest: { type: "distributed" } }, + };`, + "hosted-bundle-manifest-backend", + ], + [ + `export default { + cache: { futurePersistentCache: { path: ".tenant-cache" } }, + };`, + "hosted-cache-option", + ], + ] as const + ) { + const payload = await createPayload(source); + const error = await assertRejects( + () => evaluatePreparedDeclarativeConfigInWorker(payload), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.name, "DeclarativeConfigEvaluationError"); + assertEquals(error.code, "unsupported-hosted-feature"); + assertEquals(error.phase, "result"); + assertEquals(error.reason, reason); + assertEquals(error.retryable, false); + assertEquals(error.location?.fileName, "veryfront.config.ts"); + } +}); + +Deno.test("declarative config worker preserves validated JS and MJS diagnostic names", async () => { + for ( + const fileName of [ + "veryfront.config.js", + "veryfront.config.mjs", + ] as const + ) { + const payload = await createPayload( + "export default { secret: process.env.SECRET };", + fileName, + ); + const error = await assertRejects( + () => evaluatePreparedDeclarativeConfigInWorker(payload), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.location?.fileName, fileName); + } +}); + +Deno.test("declarative config worker rejects a pre-aborted request without starting", async () => { + const payload = await createPayload("export default { ready: true };"); + const controller = new AbortController(); + controller.abort(); + let factoryCalls = 0; + + const error = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithEndpointFactory( + payload, + { signal: controller.signal }, + async () => { + factoryCalls += 1; + throw new Error("endpoint must not start"); + }, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(factoryCalls, 0); + assertEquals(error.code, "evaluator-unavailable"); + assertEquals(error.phase, "worker"); + assertEquals(error.reason, "worker-aborted"); + assertEquals(error.retryable, false); +}); + +Deno.test("declarative config worker terminates a stalled endpoint at its deadline", async () => { + const payload = await createPayload("export default { ready: true };"); + let terminationCount = 0; + + const error = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithEndpointFactory( + payload, + { timeoutMs: 1 }, + async () => ({ + postMessage() {}, + subscribe() { + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }), + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.code, "evaluator-unavailable"); + assertEquals(error.phase, "worker"); + assertEquals(error.reason, "worker-timeout"); + assertEquals(error.retryable, true); + assertEquals(terminationCount, 1); +}); + +Deno.test("declarative config worker caller deadline does not await a stuck termination", async () => { + const payload = await createPayload("export default { ready: true };"); + let terminationCount = 0; + const result = declarativeConfigWorkerRunnerInternals + .evaluateWithEndpointFactory( + payload, + { timeoutMs: 1 }, + async () => ({ + postMessage() {}, + subscribe() { + return () => {}; + }, + terminate() { + terminationCount += 1; + return new Promise(() => {}); + }, + }), + 5, + ); + + let watchdog: ReturnType | undefined; + const callerStalled = new Promise<{ kind: "caller-stalled" }>((resolve) => { + watchdog = setTimeout(() => resolve({ kind: "caller-stalled" }), 50); + }); + const outcome = await Promise.race([ + result.then( + (value) => ({ kind: "resolved" as const, value }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ), + callerStalled, + ]); + if (watchdog !== undefined) clearTimeout(watchdog); + + assert(outcome.kind === "rejected", "caller result must reject independently"); + assert(outcome.error instanceof DeclarativeConfigEvaluationError); + assertEquals(outcome.error.reason, "worker-timeout"); + assertEquals(terminationCount, 1); + await new Promise((resolve) => setTimeout(resolve, 10)); +}); + +Deno.test("declarative config worker contains a termination rejection after its drain bound", async () => { + const payload = await createPayload("export default { ready: true };"); + let onMessage: ((value: unknown) => void) | undefined; + + const snapshot = await declarativeConfigWorkerRunnerInternals + .evaluateWithEndpointFactory( + payload, + { timeoutMs: 100 }, + async () => ({ + postMessage() { + onMessage?.({ ok: true, snapshot: { ready: true } }); + }, + subscribe(listeners) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() { + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("late termination failure")), 20); + }); + }, + }), + 5, + ); + + assertEquals(snapshot, { ready: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); +}); + +Deno.test("declarative config worker aborts in flight and removes endpoint listeners", async () => { + const payload = await createPayload("export default { ready: true };"); + const controller = new AbortController(); + let terminationCount = 0; + let unsubscribeCount = 0; + + const pending = declarativeConfigWorkerRunnerInternals + .evaluateWithEndpointFactory( + payload, + { signal: controller.signal }, + async () => ({ + postMessage() { + controller.abort(); + }, + subscribe() { + return () => { + unsubscribeCount += 1; + }; + }, + terminate() { + terminationCount += 1; + }, + }), + ); + + const error = await assertRejects( + () => pending, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.reason, "worker-aborted"); + assertEquals(terminationCount, 1); + assertEquals(unsubscribeCount, 1); +}); + +Deno.test("declarative config worker deadline includes asynchronous startup", async () => { + const payload = await createPayload("export default { ready: true };"); + let resolveFactory: + | ((endpoint: { + postMessage(): void; + subscribe(): () => void; + terminate(): void; + }) => void) + | undefined; + let terminationCount = 0; + const factoryPromise = new Promise<{ + postMessage(): void; + subscribe(): () => void; + terminate(): void; + }>((resolve) => { + resolveFactory = resolve; + }); + + const error = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithEndpointFactory( + payload, + { timeoutMs: 1 }, + () => factoryPromise, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.reason, "worker-timeout"); + resolveFactory?.({ + postMessage() {}, + subscribe() { + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }); + await Promise.resolve(); + await Promise.resolve(); + assertEquals(terminationCount, 1); +}); + +Deno.test("declarative config worker settles once when a response wins the deadline", async () => { + const payload = await createPayload("export default { ready: true };"); + let terminationCount = 0; + let onMessage: ((value: unknown) => void) | undefined; + + const snapshot = await declarativeConfigWorkerRunnerInternals + .evaluateWithEndpointFactory( + payload, + { timeoutMs: 10 }, + async () => ({ + postMessage() { + onMessage?.({ ok: true, snapshot: { ready: true } }); + }, + subscribe(listeners) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 15)); + assertEquals(snapshot, { ready: true }); + assertEquals(terminationCount, 1); +}); + +Deno.test("declarative config worker cleans up a synchronous subscription failure", async () => { + const payload = await createPayload("export default { ready: true };"); + let postCount = 0; + let terminationCount = 0; + let unsubscribeCount = 0; + + const error = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithEndpointFactory( + payload, + {}, + async () => ({ + postMessage() { + postCount += 1; + }, + subscribe(listeners) { + listeners.onError(); + return () => { + unsubscribeCount += 1; + }; + }, + terminate() { + terminationCount += 1; + }, + }), + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.reason, "worker-unavailable"); + assertEquals(postCount, 0); + assertEquals(terminationCount, 1); + assertEquals(unsubscribeCount, 1); +}); + +Deno.test("declarative config worker bounds active and queued evaluations", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 1); + const messageListeners: Array<(value: unknown) => void> = []; + let factoryCalls = 0; + let terminationCount = 0; + + const endpointFactory = async () => { + factoryCalls += 1; + return { + postMessage() {}, + subscribe(listeners: { onMessage(value: unknown): void }) { + messageListeners.push(listeners.onMessage); + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }; + }; + + const first = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1_000 }, + endpointFactory, + admission, + ); + await Promise.resolve(); + await Promise.resolve(); + assertEquals(factoryCalls, 1); + assertEquals(admission.snapshot(), { active: 1, queued: 0 }); + + const second = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1_000 }, + endpointFactory, + admission, + ); + await Promise.resolve(); + assertEquals(factoryCalls, 1); + assertEquals(admission.snapshot(), { active: 1, queued: 1 }); + + const overload = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithAdmissionController( + payload, + { timeoutMs: 1_000 }, + endpointFactory, + admission, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(overload.reason, "worker-overloaded"); + assertEquals(overload.retryable, true); + assertEquals(factoryCalls, 1); + + messageListeners[0]?.({ ok: true, snapshot: { sequence: 1 } }); + assertEquals(await first, { sequence: 1 }); + await Promise.resolve(); + await Promise.resolve(); + assertEquals(factoryCalls, 2); + assertEquals(admission.snapshot(), { active: 1, queued: 0 }); + await waitForCondition( + "the queued evaluation to install its message listener", + () => messageListeners.length, + (listenerCount) => listenerCount >= 2, + ); + assertEquals(messageListeners.length, 2); + + messageListeners[1]?.({ ok: true, snapshot: { sequence: 2 } }); + assertEquals(await second, { sequence: 2 }); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(terminationCount, 2); +}); + +Deno.test("declarative config worker bounds factories that never settle without retaining ordinary admission", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + const startup = declarativeConfigWorkerRunnerInternals + .createStartupController(1); + let factoryCalls = 0; + const endpointFactory = () => { + factoryCalls += 1; + return new Promise(() => {}); + }; + + const first = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1 }, + endpointFactory, + admission, + 5, + startup, + ); + const timeoutError = await assertRejects( + () => first, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(timeoutError.reason, "worker-timeout"); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(startup.snapshot(), { pending: 1, maxPending: 1 }); + assertEquals(factoryCalls, 1); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const overload = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + endpointFactory, + admission, + 5, + startup, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(overload.reason, "worker-overloaded"); + } + assertEquals(factoryCalls, 1); + assertEquals(admission.snapshot(), { active: 0, queued: 0 }); + assertEquals(startup.snapshot(), { pending: 1, maxPending: 1 }); +}); + +Deno.test("declarative config worker releases ordinary admission when an orphan factory is aborted", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + const startup = declarativeConfigWorkerRunnerInternals + .createStartupController(1); + const controller = new AbortController(); + let factoryCalls = 0; + + const evaluation = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { signal: controller.signal, timeoutMs: 1_000 }, + () => { + factoryCalls += 1; + return new Promise(() => {}); + }, + admission, + 5, + startup, + ); + await Promise.resolve(); + controller.abort(); + + const error = await assertRejects( + () => evaluation, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-aborted"); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(factoryCalls, 1); + assertEquals(startup.snapshot(), { pending: 1, maxPending: 1 }); +}); + +Deno.test("declarative config worker terminates a late factory result and recovers startup capacity", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + const startup = declarativeConfigWorkerRunnerInternals + .createStartupController(1); + const firstFactory = Promise.withResolvers<{ + postMessage(): void; + subscribe(): () => void; + terminate(): void; + }>(); + let factoryCalls = 0; + let firstPostCount = 0; + let terminationCount = 0; + let nextMessage: ((value: unknown) => void) | undefined; + + const endpointFactory = () => { + factoryCalls += 1; + if (factoryCalls === 1) return firstFactory.promise; + return Promise.resolve({ + postMessage() { + nextMessage?.({ ok: true, snapshot: { recovered: true } }); + }, + subscribe(listeners: { onMessage(value: unknown): void }) { + nextMessage = listeners.onMessage; + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }); + }; + + const first = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1 }, + endpointFactory, + admission, + 5, + startup, + ); + const timeoutError = await assertRejects( + () => first, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(timeoutError.reason, "worker-timeout"); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(startup.snapshot(), { pending: 1, maxPending: 1 }); + + const overload = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + endpointFactory, + admission, + 5, + startup, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(overload.reason, "worker-overloaded"); + assertEquals(factoryCalls, 1); + + firstFactory.resolve({ + postMessage() { + firstPostCount += 1; + }, + subscribe() { + return () => {}; + }, + terminate() { + terminationCount += 1; + }, + }); + await waitForStartupState(startup, { pending: 0, maxPending: 1 }); + assertEquals(firstPostCount, 0); + assertEquals(terminationCount, 1); + + const recovered = await declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + endpointFactory, + admission, + 5, + startup, + ); + assertEquals(recovered, { recovered: true }); + assertEquals(factoryCalls, 2); + assertEquals(terminationCount, 2); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(startup.snapshot(), { pending: 0, maxPending: 1 }); +}); + +Deno.test("declarative config worker releases both capacity classes when a factory throws", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + const startup = declarativeConfigWorkerRunnerInternals + .createStartupController(1); + let factoryCalls = 0; + let onMessage: ((value: unknown) => void) | undefined; + + const endpointFactory = () => { + factoryCalls += 1; + if (factoryCalls === 1) throw new Error("startup failed"); + return Promise.resolve({ + postMessage() { + onMessage?.({ ok: true, snapshot: { recovered: true } }); + }, + subscribe(listeners: { onMessage(value: unknown): void }) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() {}, + }); + }; + + const error = await assertRejects( + () => + declarativeConfigWorkerRunnerInternals.evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + endpointFactory, + admission, + 5, + startup, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-unavailable"); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + await waitForStartupState(startup, { pending: 0, maxPending: 1 }); + + const recovered = await declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + endpointFactory, + admission, + 5, + startup, + ); + assertEquals(recovered, { recovered: true }); + assertEquals(factoryCalls, 2); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + assertEquals(startup.snapshot(), { pending: 0, maxPending: 1 }); +}); + +Deno.test("declarative config worker lifecycle uses captured primordials after shared-realm poisoning", async () => { + const payload = await createPayload("export default { ready: true };"); + const preAbortedSignal = AbortSignal.abort(); + const liveSignal = new AbortController().signal; + const restores: Array<() => void> = []; + let poisonCalls = 0; + const poison = () => { + poisonCalls += 1; + throw new Error("ambient lifecycle primordial must not run"); + }; + const replace = ( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, + ) => { + restores[restores.length] = replacePropertyForTest(target, key, descriptor); + }; + + let preAbortedError: unknown; + let expiredError: unknown; + let evaluationResult: unknown; + let admissionState: unknown; + let startupState: unknown; + try { + for ( + const key of ["push", "shift", "indexOf", "splice"] as const + ) { + replace(Array.prototype, key, { value: poison, writable: true }); + } + replace(Promise, "resolve", { value: poison, writable: true }); + replace(Promise, "reject", { value: poison, writable: true }); + replace(Promise.prototype, "then", { value: poison, writable: true }); + replace(Object, "freeze", { value: poison, writable: true }); + replace(Number, "isSafeInteger", { value: poison, writable: true }); + replace(Math, "ceil", { value: poison, writable: true }); + replace(AbortSignal.prototype, "aborted", { get: poison }); + replace(EventTarget.prototype, "addEventListener", { + value: poison, + writable: true, + }); + replace(EventTarget.prototype, "removeEventListener", { + value: poison, + writable: true, + }); + replace(globalThis, "setTimeout", { value: poison, writable: true }); + replace(globalThis, "clearTimeout", { value: poison, writable: true }); + replace(Reflect, "apply", { value: poison, writable: true }); + + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 2); + const startup = declarativeConfigWorkerRunnerInternals + .createStartupController(1); + + try { + await admission.acquire(1_000, preAbortedSignal); + } catch (error) { + preAbortedError = error; + } + const releaseActive = await admission.acquire(1_000); + const expired = admission.acquire(1, liveSignal); + try { + await expired; + } catch (error) { + expiredError = error; + } + const dispatched = admission.acquire(1_000, liveSignal); + releaseActive(); + const releaseDispatched = await dispatched; + releaseDispatched(); + + let onMessage: ((value: unknown) => void) | undefined; + evaluationResult = await declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + async () => ({ + postMessage() { + onMessage?.({ ok: true, snapshot: { captured: true } }); + }, + subscribe(listeners) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() {}, + }), + admission, + 5, + startup, + ); + await waitForCondition( + "captured-primordial worker lifecycle cleanup", + () => ({ + admission: admission.snapshot(), + startup: startup.snapshot(), + }), + (current) => + current.admission.active === 0 && + current.startup.pending === 0, + ); + admissionState = admission.snapshot(); + startupState = startup.snapshot(); + } finally { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.(); + } + } + + assert(preAbortedError instanceof DeclarativeConfigEvaluationError); + assertEquals(preAbortedError.reason, "worker-aborted"); + assert(expiredError instanceof DeclarativeConfigEvaluationError); + assertEquals(expiredError.reason, "worker-timeout"); + assertEquals(evaluationResult, { captured: true }); + assertEquals(admissionState, { active: 0, queued: 0 }); + assertEquals(startupState, { pending: 0, maxPending: 1 }); + assertEquals(poisonCalls, 0); +}); + +Deno.test("declarative config worker releases admission after a stuck termination drain bound", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + let terminationCount = 0; + + const first = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1 }, + async () => ({ + postMessage() {}, + subscribe() { + return () => {}; + }, + terminate() { + terminationCount += 1; + return new Promise(() => {}); + }, + }), + admission, + 5, + ); + + const timeoutError = await assertRejects( + () => first, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(timeoutError.reason, "worker-timeout"); + assertEquals(terminationCount, 1); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); + + let onMessage: ((value: unknown) => void) | undefined; + const second = await declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + async () => ({ + postMessage() { + onMessage?.({ ok: true, snapshot: { recovered: true } }); + }, + subscribe(listeners) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() {}, + }), + admission, + 5, + ); + + assertEquals(second, { recovered: true }); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); +}); + +Deno.test("declarative config worker preserves a successful result when terminate throws", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 0); + let onMessage: ((value: unknown) => void) | undefined; + + const snapshot = await declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 100 }, + async () => ({ + postMessage() { + onMessage?.({ ok: true, snapshot: { ready: true } }); + }, + subscribe(listeners) { + onMessage = listeners.onMessage; + return () => {}; + }, + terminate() { + throw new Error("termination failed"); + }, + }), + admission, + 5, + ); + + assertEquals(snapshot, { ready: true }); + await waitForAdmissionState(admission, { active: 0, queued: 0 }); +}); + +Deno.test("declarative config worker removes aborted and expired queue entries", async () => { + const payload = await createPayload("export default { ready: true };"); + const admission = declarativeConfigWorkerRunnerInternals + .createAdmissionController(1, 2); + let activeListener: ((value: unknown) => void) | undefined; + let factoryCalls = 0; + + const endpointFactory = async () => { + factoryCalls += 1; + return { + postMessage() {}, + subscribe(listeners: { onMessage(value: unknown): void }) { + activeListener = listeners.onMessage; + return () => {}; + }, + terminate() {}, + }; + }; + + const active = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1_000 }, + endpointFactory, + admission, + ); + await Promise.resolve(); + await Promise.resolve(); + + const controller = new AbortController(); + const aborted = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { signal: controller.signal, timeoutMs: 1_000 }, + endpointFactory, + admission, + ); + await Promise.resolve(); + controller.abort(); + const abortError = await assertRejects( + () => aborted, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(abortError.reason, "worker-aborted"); + + const expired = declarativeConfigWorkerRunnerInternals + .evaluateWithAdmissionController( + payload, + { timeoutMs: 1 }, + endpointFactory, + admission, + ); + const timeoutError = await assertRejects( + () => expired, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(timeoutError.reason, "worker-timeout"); + assertEquals(admission.snapshot(), { active: 1, queued: 0 }); + assertEquals(factoryCalls, 1); + + activeListener?.({ ok: true, snapshot: { ready: true } }); + await active; + await waitForAdmissionState(admission, { active: 0, queued: 0 }); +}); + +Deno.test("declarative config worker protocol rejects malformed responses", () => { + assertEquals(DECLARATIVE_CONFIG_WORKER_PROTOCOL_VERSION, 2); + + for ( + const response of [ + null, + {}, + { ok: true }, + { ok: true, snapshot: {}, extra: true }, + { ok: false, error: {} }, + { + ok: false, + error: { + code: "made-up", + phase: "worker", + reason: "worker-protocol", + location: null, + retryable: false, + }, + }, + ] + ) { + const error = assertThrows( + () => decodeDeclarativeConfigWorkerResponse(response, 100), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, "evaluator-unavailable"); + assertEquals(error.phase, "worker"); + assertEquals(error.reason, "worker-protocol"); + } +}); + +Deno.test("declarative config worker protocol rejects hostile descriptors and tuples", () => { + let getterCalls = 0; + const accessorResponse = {}; + Object.defineProperty(accessorResponse, "ok", { + enumerable: true, + get() { + getterCalls += 1; + return true; + }, + }); + const customPrototypeResponse = Object.create({ inherited: true }); + Object.assign(customPrototypeResponse, { ok: true, snapshot: {} }); + + for ( + const response of [ + accessorResponse, + customPrototypeResponse, + { ok: true, snapshot: {}, [Symbol("extra")]: true }, + { + ok: false, + error: { + code: "source-too-large", + phase: "result", + reason: "syntax-error", + location: null, + retryable: true, + }, + }, + { + ok: false, + error: { + code: "syntax-error", + phase: "parse", + reason: "syntax-error", + location: { + line: 1, + column: 5, + offset: 5, + fileName: "veryfront.config.ts", + }, + retryable: false, + }, + }, + { + ok: true, + snapshot: { oversized: new Array(2_049).fill(true) }, + }, + ] + ) { + const error = assertThrows( + () => decodeDeclarativeConfigWorkerResponse(response, 4), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-protocol"); + } + assertEquals(getterCalls, 0); +}); + +Deno.test("declarative config worker protocol binds response locations to the request filename", () => { + const error = assertThrows( + () => + decodeDeclarativeConfigWorkerResponse( + { + ok: false, + error: { + code: "syntax-error", + phase: "parse", + reason: "syntax-error", + location: { + line: 1, + column: 0, + offset: 0, + fileName: "veryfront.config.ts", + }, + retryable: false, + }, + }, + 10, + "veryfront.config.js", + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-protocol"); +}); + +Deno.test("declarative config worker protocol rejects hostile requests without getters", async () => { + const payload = await createPayload("export default { ready: true };"); + const decoded = decodeDeclarativeConfigWorkerRequest(payload); + assertEquals(Object.getPrototypeOf(decoded), null); + assertEquals(Object.isFrozen(decoded), true); + + let getterCalls = 0; + const hostileEnvironment = {}; + Object.defineProperty(hostileEnvironment, "SECRET", { + enumerable: true, + get() { + getterCalls += 1; + return "host"; + }, + }); + + for ( + const request of [ + { + cacheFingerprint: "ctx1:not-a-digest", + policyVersion: payload.policyVersion, + evaluationOptions: payload.evaluationOptions, + }, + { + cacheFingerprint: payload.cacheFingerprint, + policyVersion: "hosted-declarative-config-v1", + evaluationOptions: payload.evaluationOptions, + }, + { + cacheFingerprint: payload.cacheFingerprint, + policyVersion: payload.policyVersion, + evaluationOptions: { + source: payload.evaluationOptions.source, + fileName: payload.evaluationOptions.fileName, + environmentName: payload.evaluationOptions.environmentName, + environment: hostileEnvironment, + }, + }, + { + cacheFingerprint: payload.cacheFingerprint, + policyVersion: payload.policyVersion, + evaluationOptions: { + ...payload.evaluationOptions, + fileName: "../../tenant/veryfront.config.ts", + }, + }, + { + cacheFingerprint: payload.cacheFingerprint, + policyVersion: payload.policyVersion, + evaluationOptions: payload.evaluationOptions, + [Symbol("extra")]: true, + }, + ] + ) { + const error = assertThrows( + () => decodeDeclarativeConfigWorkerRequest(request), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-protocol"); + } + assertEquals(getterCalls, 0); +}); + +Deno.test("declarative config worker protocol rebuilds cloned success data", () => { + const decoded = decodeDeclarativeConfigWorkerResponse({ + ok: true, + snapshot: { + z: { value: 2 }, + a: [1, 2], + }, + }, 100); + + assert(decoded.ok); + assertEquals(decoded.snapshot, { + a: [1, 2], + z: { value: 2 }, + }); + assertEquals(Object.getPrototypeOf(decoded.snapshot), null); + assertEquals(Object.getPrototypeOf(decoded.snapshot.z), null); + assertEquals(Object.isFrozen(decoded.snapshot), true); + assertEquals(Object.isFrozen(decoded.snapshot.a), true); +}); + +Deno.test("declarative config worker protocol ignores inherited descriptor fields during response round trips", () => { + const restores: Array<() => void> = []; + let inheritedDescriptorGetterCalls = 0; + const inheritedDescriptorGetter = () => { + inheritedDescriptorGetterCalls += 1; + throw new Error("inherited descriptor field must not be read"); + }; + let decodedSuccess: + | ReturnType + | undefined; + let decodedError: unknown; + + try { + for ( + const key of [ + "configurable", + "enumerable", + "value", + "writable", + "get", + "set", + ] as const + ) { + restores[restores.length] = replacePropertyForTest( + Object.prototype, + key, + { get: inheritedDescriptorGetter }, + ); + } + + const successResponse = createDeclarativeConfigWorkerSuccessResponse({ + nested: { enabled: true }, + title: "production", + }); + decodedSuccess = decodeDeclarativeConfigWorkerResponse( + successResponse, + 100, + ); + + const errorResponse = createDeclarativeConfigWorkerErrorResponse( + new DeclarativeConfigEvaluationError({ + code: "syntax-error", + phase: "parse", + reason: "syntax-error", + location: { + line: 2, + column: 3, + offset: 12, + fileName: "veryfront.config.ts", + }, + retryable: false, + }), + ); + try { + decodeDeclarativeConfigWorkerResponse(errorResponse, 100); + } catch (error) { + decodedError = error; + } + } finally { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]!(); + } + } + + assertEquals(inheritedDescriptorGetterCalls, 0); + assertEquals(decodedSuccess, { + ok: true, + snapshot: { + nested: { enabled: true }, + title: "production", + }, + }); + assert(decodedError instanceof DeclarativeConfigEvaluationError); + assertEquals( + { + code: decodedError.code, + phase: decodedError.phase, + reason: decodedError.reason, + location: decodedError.location, + retryable: decodedError.retryable, + }, + { + code: "syntax-error", + phase: "parse", + reason: "syntax-error", + location: { + line: 2, + column: 3, + offset: 12, + fileName: "veryfront.config.ts", + }, + retryable: false, + }, + ); +}); diff --git a/src/config/declarative-evaluator.test.ts b/src/config/declarative-evaluator.test.ts new file mode 100644 index 0000000000..4686f93b37 --- /dev/null +++ b/src/config/declarative-evaluator.test.ts @@ -0,0 +1,1627 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + assertEquals, + assertMatch, + assertNotEquals, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { PROJECT_ENV_SNAPSHOT_LIMITS } from "#veryfront/platform/compat/process/project-env-contract.ts"; +import { MAX_HOSTED_RENDER_CACHE_ENTRIES } from "./defaults.ts"; +import { + createPreparedDeclarativeConfigWorkerPayload, + DECLARATIVE_CONFIG_LIMITS, + type DeclarativeConfigErrorCode, + type DeclarativeConfigErrorReason, + DeclarativeConfigEvaluationError, + evaluateDeclarativeConfig, + evaluateDeclarativeConfigWithParser, + prepareDeclarativeConfigContext, +} from "./declarative-evaluator.ts"; + +const DEFAULT_OPTIONS = Object.freeze({ + environmentName: "production", + environment: Object.freeze({ NODE_ENV: "production" }), +}); + +async function assertEvaluationError( + source: string, + code: DeclarativeConfigErrorCode, + reason: DeclarativeConfigErrorReason, + overrides: Readonly<{ + environmentName?: string; + environment?: unknown; + }> = {}, +): Promise { + const error = await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: overrides.environmentName ?? + DEFAULT_OPTIONS.environmentName, + environment: overrides.environment ?? DEFAULT_OPTIONS.environment, + }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, code); + assertEquals(error.reason, reason); + return error; +} + +function repeatedList(value: string, count: number): string { + return new Array(count).fill(value).join(","); +} + +async function runPermissionlessWorker(source: string): Promise { + const workerUrl = URL.createObjectURL( + new Blob([source], { type: "text/javascript" }), + ); + const workerOptions: WorkerOptions & { + deno: { permissions: "none" }; + } = { + type: "module", + deno: { permissions: "none" }, + }; + const worker = new Worker(workerUrl, workerOptions); + + try { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Permissionless evaluator Worker timed out")); + }, 5_000); + worker.onmessage = (event: MessageEvent) => { + clearTimeout(timeout); + resolve(event.data); + }; + worker.onerror = (event: ErrorEvent) => { + event.preventDefault(); + clearTimeout(timeout); + reject(new Error(`Permissionless evaluator Worker failed: ${event.message}`)); + }; + }); + } finally { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + } +} + +describe("evaluateDeclarativeConfig", () => { + it("evaluates a representative documented configuration as a frozen snapshot", async () => { + const snapshot = await evaluateDeclarativeConfig({ + source: ` +import { defineConfig } from "veryfront"; + +export default defineConfig({ + title: "My App", + description: "A production app", + router: "app", + directories: { + app: "app", + components: ["components", "ui"], + }, + build: { + outDir: "dist", + trailingSlash: false, + ssg: true, + }, + security: { + remoteHosts: ["https://esm.sh"], + }, +}); +`, + environmentName: "production", + environment: {}, + }); + + assertEquals(snapshot, { + build: { outDir: "dist", ssg: true, trailingSlash: false }, + description: "A production app", + directories: { + app: "app", + components: ["components", "ui"], + }, + router: "app", + security: { remoteHosts: ["https://esm.sh"] }, + title: "My App", + }); + assertEquals(Object.getPrototypeOf(snapshot), null); + assertEquals(Object.getPrototypeOf(snapshot.build), null); + assertEquals(Object.isFrozen(snapshot), true); + assertEquals(Object.isFrozen(snapshot.build), true); + assertEquals(Object.isFrozen(snapshot.directories), true); + assertEquals( + Object.isFrozen( + (snapshot.directories as { components: readonly string[] }).components, + ), + true, + ); + assertThrows( + () => Object.defineProperty(snapshot, "title", { value: "mutated" }), + TypeError, + ); + }); + + it("loads the parser-only evaluator graph in a permissionless Deno Worker", async () => { + const evaluatorUrl = new URL( + "./declarative-evaluator.ts", + import.meta.url, + ).href; + const parserUrl = new URL( + "../../extensions/ext-parser-babel/src/parser-only.ts", + import.meta.url, + ).href; + const result = await runPermissionlessWorker(` + import { evaluateDeclarativeConfigWithParser } from ${JSON.stringify(evaluatorUrl)}; + import { BabelParseOnlyParser } from ${JSON.stringify(parserUrl)}; + + const snapshot = await evaluateDeclarativeConfigWithParser({ + source: 'import { getEnv } from "veryfront"; export default { title: getEnv("TENANT") ?? "missing", nested: { enabled: true } };', + environmentName: "production", + environment: { TENANT: "isolated" }, + }, new BabelParseOnlyParser()); + async function isDenied(operation) { + try { + await operation(); + return false; + } catch (error) { + return error instanceof Deno.errors.NotCapable; + } + } + globalThis.postMessage({ + snapshotSummary: { + title: snapshot.title, + nestedEnabled: snapshot.nested.enabled, + }, + snapshotInvariants: { + rootNullPrototype: Object.getPrototypeOf(snapshot) === null, + rootFrozen: Object.isFrozen(snapshot), + nestedNullPrototype: Object.getPrototypeOf(snapshot.nested) === null, + nestedFrozen: Object.isFrozen(snapshot.nested), + }, + deniedCapabilities: { + env: await isDenied(() => Deno.env.get("SECRET")), + read: await isDenied(() => Deno.readTextFile(${JSON.stringify(evaluatorUrl)})), + net: await isDenied(() => fetch("http://127.0.0.1:9/")), + }, + }); + `); + + assertEquals(result, { + snapshotSummary: { + title: "isolated", + nestedEnabled: true, + }, + snapshotInvariants: { + rootNullPrototype: true, + rootFrozen: true, + nestedNullPrototype: true, + nestedFrozen: true, + }, + deniedCapabilities: { + env: true, + read: true, + net: true, + }, + }); + }); + + it("keeps the evaluator and parser-only runtime graph free of full Babel tooling", async () => { + const entrypoints = [ + new URL("./declarative-evaluator.ts", import.meta.url).href, + new URL( + "../../extensions/ext-parser-babel/src/parser-only.ts", + import.meta.url, + ).href, + ]; + const outputs = await Promise.all( + entrypoints.map((entrypoint) => + new Deno.Command(Deno.execPath(), { + args: ["info", "--json", entrypoint], + stdout: "piped", + stderr: "piped", + }).output() + ), + ); + let graph = ""; + const reachableNpmNames = new Set(); + for (const output of outputs) { + assertEquals( + output.success, + true, + new TextDecoder().decode(output.stderr), + ); + const info = JSON.parse(new TextDecoder().decode(output.stdout)) as { + modules?: Array<{ + kind?: string; + specifier?: string; + npmPackage?: string; + }>; + npmPackages?: Record< + string, + { + name?: string; + dependencies?: string[]; + } + >; + }; + graph += (info.modules ?? []) + .map((module) => module.specifier ?? "") + .join("\n"); + const packages = info.npmPackages ?? {}; + const pending = (info.modules ?? []) + .filter((module) => module.kind === "npm") + .flatMap((module) => typeof module.npmPackage === "string" ? [module.npmPackage] : []); + const visited = new Set(); + while (pending.length > 0) { + const packageId = pending.pop()!; + if (visited.has(packageId)) continue; + visited.add(packageId); + const packageInfo = packages[packageId]; + if (!packageInfo) continue; + if (typeof packageInfo.name === "string") { + reachableNpmNames.add(packageInfo.name); + } + for (const dependency of packageInfo.dependencies ?? []) { + pending.push(dependency); + } + } + } + assertEquals(graph.includes("@babel/traverse"), false); + assertEquals(graph.includes("@babel/generator"), false); + assertEquals(/(?:^|[/+:])debug@/m.test(graph), false); + assertEquals(reachableNpmNames.has("@babel/parser"), true); + assertEquals(reachableNpmNames.has("@babel/traverse"), false); + assertEquals(reachableNpmNames.has("@babel/generator"), false); + assertEquals(reachableNpmNames.has("debug"), false); + assertEquals(graph.includes("/rendering/cache/stores/redis-store.ts"), false); + assertEquals(graph.includes("/rendering/cache/stores/kv-store.ts"), false); + assertEquals(graph.includes("/rendering/cache/stores/filesystem-store.ts"), false); + assertEquals(graph.includes("/utils/redis-client.ts"), false); + assertEquals(reachableNpmNames.has("@redis/client"), false); + }); + + it("supports helper aliases, safe spreads, environment branching, templates, and TS wrappers", async () => { + const snapshot = await evaluateDeclarativeConfig({ + source: ` +import { + defineConfig as config, + defineConfigWithEnv as byEnvironment, + getEnv as tenantEnv, + mergeConfigs as merge, + type VeryfrontConfig, +} from "veryfront"; + +interface LocalConfig { title: string } +type RouterMode = "app" | "pages"; +const router = "app" as const; +const baseDirectories = ["app"]; + const base = { + router, + description: "Very" + "front", + build: { outDir: "dist", ssg: 1 + 1 === 2 && !false }, +} satisfies VeryfrontConfig; +const selected = byEnvironment((environment) => ({ + title: environment === "production" + ? \`Production-\${tenantEnv("REGION") ?? "unknown"}\` + : "Development", + region: tenantEnv("deployment.region") ?? "missing", + featureFlag: tenantEnv("feature-flag") ?? "off", + dev: { port: 3000 + 2 }, +})); +const extraDirectories = ["components"]; + +export default config(merge( + base, + selected, + { directories: [...baseDirectories, ...extraDirectories] }, +) as const); +`, + environmentName: "production", + environment: { + REGION: "eu", + "deployment.region": "eu-west", + "feature-flag": "on", + }, + }); + + assertEquals(snapshot, { + build: { outDir: "dist", ssg: true }, + description: "Veryfront", + dev: { port: 3002 }, + directories: ["app", "components"], + featureFlag: "on", + region: "eu-west", + router: "app", + title: "Production-eu", + }); + }); + + it("never falls through to host state and isolates concurrent tenant maps", async () => { + const source = ` +import { defineConfig, getEnv } from "veryfront"; +export default defineConfig({ + title: getEnv("TENANT_NAME") ?? "missing", + description: getEnv("HOST_ONLY") ?? "not-visible", +}); +`; + const [first, second, empty] = await Promise.all([ + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: { TENANT_NAME: "first" }, + }), + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: { TENANT_NAME: "second" }, + }), + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + ]); + + assertEquals(first, { description: "not-visible", title: "first" }); + assertEquals(second, { description: "not-visible", title: "second" }); + assertEquals(empty, { description: "not-visible", title: "missing" }); + }); + + it("descriptor-validates tenant environment without invoking getters", async () => { + let getterCalls = 0; + const environment = Object.create(null) as Record; + Object.defineProperty(environment, "SECRET", { + enumerable: true, + get() { + getterCalls += 1; + return "host-secret"; + }, + }); + + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-accessor", + { environment }, + ); + assertEquals(getterCalls, 0); + + const inherited = Object.create({ SECRET: "inherited" }); + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-prototype", + { environment: inherited }, + ); + }); + + it("rejects every non-bare, side-effect, default, namespace, and attributed import", async () => { + const sources = [ + 'import value from "veryfront"; export default {};', + 'import * as vf from "veryfront"; export default {};', + 'import "veryfront"; export default {};', + 'import { defineConfig } from "./local.ts"; export default {};', + 'import type { Local } from "./local.ts"; export default {};', + 'import { defineConfig } from "node:fs"; export default {};', + 'import { defineConfig } from "https://example.com/config.ts"; export default {};', + 'import { defineConfig } from "veryfront" with { type: "json" }; export default {};', + ]; + for (const source of sources) { + const error = await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals( + error.code === "unsupported-syntax" || + error.code === "syntax-error", + true, + ); + } + }); + + it("rejects host capabilities and inactive side effects without executing them", async () => { + const marker = "__veryfrontDeclarativeEvaluatorSideEffect"; + const host = globalThis as Record; + const previousMarker = Object.getOwnPropertyDescriptor(host, marker); + Object.defineProperty(host, marker, { + value: 0, + writable: true, + configurable: true, + }); + try { + const sources = [ + `const hidden = false && globalThis.${marker}++; export default {};`, + 'const hidden = true ? null : import("./evil.ts"); export default {};', + "const hidden = false && process.exit(1); export default {};", + `const hidden = false || eval("globalThis.${marker} = 1"); export default {};`, + 'const hidden = Deno.env.get("SECRET"); export default {};', + 'const hidden = require("node:fs"); export default {};', + 'const hidden = Function("return process")(); export default {};', + ]; + for (const source of sources) { + await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ); + } + assertEquals(host[marker], 0); + } finally { + if (previousMarker) Object.defineProperty(host, marker, previousMarker); + else delete host[marker]; + } + }); + + it("validates the complete program before evaluating any earlier declaration", async () => { + const error = await assertEvaluationError( + `const wouldFailDuringEvaluation = 1 / 0; +const laterForbidden = process.env; +export default {};`, + "forbidden-capability", + "unsupported-call", + ); + assertEquals(error.phase, "validate"); + assertEquals(error.location?.line, 2); + }); + + it("rejects executable CORS, middleware, extension, and getter values", async () => { + const marker = "__veryfrontDeclarativeGetterSideEffect"; + const host = globalThis as Record; + const previousMarker = Object.getOwnPropertyDescriptor(host, marker); + Object.defineProperty(host, marker, { + value: 0, + writable: true, + configurable: true, + }); + const sources = [ + `export default { security: { cors: { origin: (origin) => origin === "safe" } } };`, + `export default { middleware: { custom: [function middleware() {}] } };`, + `export default { extensions: [extensionFactory()] };`, + `export default { get title() { globalThis.${marker} = 1; return "x"; } };`, + `export default { method() { return "x"; } };`, + ]; + try { + for (const source of sources) { + await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ); + } + assertEquals(host[marker], 0); + } finally { + if (previousMarker) Object.defineProperty(host, marker, previousMarker); + else delete host[marker]; + } + }); + + it("enforces the hosted plain-data profile after evaluation", async () => { + const accepted = await evaluateDeclarativeConfig({ + source: `export default { + extensions: [ + { name: "disabled-extension", enabled: false }, + { name: "@scope/disabled-extension", enabled: false }, + { name: "plugins/internal/disabled", enabled: false }, + ], + middleware: { custom: [] }, + security: { + cors: { + origin: ["https://one.example", "https://two.example"], + }, + }, + };`, + environmentName: "production", + environment: {}, + }); + assertEquals(accepted, { + extensions: [ + { enabled: false, name: "disabled-extension" }, + { enabled: false, name: "@scope/disabled-extension" }, + { enabled: false, name: "plugins/internal/disabled" }, + ], + middleware: { custom: [] }, + security: { + cors: { + origin: ["https://one.example", "https://two.example"], + }, + }, + }); + + for ( + const source of [ + `export default { extensions: ["plain-data"] };`, + `export default { extensions: [{ name: "materialized", version: "1", capabilities: [] }] };`, + `export default { extensions: [{ name: "disabled", enabled: false, extra: true }] };`, + ] + ) { + await assertEvaluationError( + source, + "unsupported-hosted-feature", + "hosted-extensions", + ); + } + + for ( + const name of [ + "", + " leading-space", + "trailing-space ", + "line\nbreak", + "nul\0byte", + "unit\u001fseparator", + "delete\u007fcontrol", + ] + ) { + await assertEvaluationError( + `export default { extensions: [{ name: ${JSON.stringify(name)}, enabled: false }] };`, + "unsupported-hosted-feature", + "hosted-extensions", + ); + } + + for ( + const source of [ + `export default { middleware: { custom: ["plain-data"] } };`, + `export default { middleware: { custom: [{}] } };`, + `export default { middleware: { custom: "not-an-array" } };`, + ] + ) { + await assertEvaluationError( + source, + "unsupported-hosted-feature", + "hosted-custom-middleware", + ); + } + + for ( + const source of [ + `export default { security: { cors: { origin: {} } } };`, + `export default { security: { cors: { origin: [] } } };`, + `export default { security: { cors: { origin: ["ok", 1] } } };`, + `export default { security: { cors: { origin: " leading-space" } } };`, + `export default { security: { cors: { origin: ${JSON.stringify("line\nbreak")} } } };`, + `export default { security: { cors: { origin: "snowman-☃" } } };`, + `export default { security: { cors: { origin: [${repeatedList('"x"', 65)}] } } };`, + `export default { security: { cors: { origin: [${ + repeatedList(JSON.stringify("a".repeat(2_048)), 5) + }] } } };`, + `export default { security: { cors: { origin: "${"a".repeat(2_049)}" } } };`, + ] + ) { + await assertEvaluationError( + source, + "unsupported-hosted-feature", + "hosted-cors-origin", + ); + } + }); + + it("allows only memory-backed render cache controls in hosted config", async () => { + const accepted = await evaluateDeclarativeConfig({ + source: `export default { + cache: { + bundleManifest: { + enabled: true, + type: "memory", + ttl: 60_000, + }, + queryParams: { + policy: "include-list", + params: ["page"], + }, + render: { + type: "memory", + ttl: 60_000, + maxEntries: ${MAX_HOSTED_RENDER_CACHE_ENTRIES}, + public: { + enabled: true, + varyHeaders: ["accept-language"], + }, + }, + }, + };`, + environmentName: "production", + environment: {}, + }); + assertEquals(accepted, { + cache: { + bundleManifest: { + enabled: true, + ttl: 60_000, + type: "memory", + }, + queryParams: { + params: ["page"], + policy: "include-list", + }, + render: { + maxEntries: MAX_HOSTED_RENDER_CACHE_ENTRIES, + public: { + enabled: true, + varyHeaders: ["accept-language"], + }, + ttl: 60_000, + type: "memory", + }, + }, + }); + + const directoryError = await assertEvaluationError( + `export default { cache: { dir: ".tenant-cache" } };`, + "unsupported-hosted-feature", + "hosted-cache-directory", + ); + assertEquals(directoryError.phase, "result"); + assertEquals(directoryError.retryable, false); + + const capacityError = await assertEvaluationError( + `export default { + cache: { + render: { maxEntries: ${MAX_HOSTED_RENDER_CACHE_ENTRIES + 1} }, + }, + };`, + "unsupported-hosted-feature", + "hosted-render-cache-capacity", + ); + assertEquals(capacityError.phase, "result"); + assertEquals(capacityError.retryable, false); + + for ( + const bundleManifest of [ + `{ type: "distributed" }`, + `{ endpoint: "https://cache.invalid" }`, + `{ type: "memory", futureBackendTarget: "cache.invalid" }`, + ] + ) { + await assertEvaluationError( + `export default { + cache: { bundleManifest: ${bundleManifest} }, + };`, + "unsupported-hosted-feature", + "hosted-bundle-manifest-backend", + ); + } + + await assertEvaluationError( + `export default { + cache: { futurePersistentCache: { path: ".tenant-cache" } }, + };`, + "unsupported-hosted-feature", + "hosted-cache-option", + ); + + for ( + const render of [ + `{ type: "filesystem" }`, + `{ type: "kv" }`, + `{ type: "distributed" }`, + `{ type: "disk" }`, + `{ kvPath: ".tenant-cache/render.kv" }`, + `{ endpoint: "https://cache.invalid" }`, + `{ keyPrefix: "vf:cache:tenant-render:" }`, + `{ type: "memory", kvPath: ".tenant-cache/render.kv" }`, + `{ type: "memory", endpoint: "https://cache.invalid" }`, + `{ type: "memory", keyPrefix: "vf:cache:tenant-render:" }`, + `{ type: "memory", storagePath: ".tenant-cache/future" }`, + ] + ) { + const error = await assertEvaluationError( + `export default { cache: { render: ${render} } };`, + "unsupported-hosted-feature", + "hosted-render-cache-backend", + ); + assertEquals(error.phase, "result"); + assertEquals(error.retryable, false); + } + }); + + it("rejects pollution keys, duplicate normalized keys, and shared aliases", async () => { + for (const key of ["__proto__", "constructor", "prototype"]) { + await assertEvaluationError( + `export default { ${JSON.stringify(key)}: true };`, + "forbidden-capability", + "dangerous-key", + ); + } + await assertEvaluationError( + `export default { 1: "numeric", "1": "string" };`, + "invalid-result", + "duplicate-key", + ); + await assertEvaluationError( + `const shared = { enabled: true }; export default { first: shared, second: shared };`, + "invalid-result", + "result-not-snapshot-safe", + ); + }); + + it("rejects mutation, loops, classes, new/member calls, runtime TS, and dynamic values", async () => { + const sources = [ + "let value = 1; export default {};", + "var value = 1; export default {};", + "const value = 1; value = 2; export default {};", + "const value = 1; value++; export default {};", + "for (;;) { break; } export default {};", + "while (false) {} export default {};", + "class Config {} export default {};", + "enum Mode { App } export default {};", + "namespace Runtime {} export default {};", + "export default new Date();", + "const value = {}; export default value.toString();", + "export default (() => ({}))();", + "export default /pattern/;", + "export default 1n;", + "export default [1,,2];", + "export default await Promise.resolve({});", + "const { value } = { value: 1 }; export default {};", + ]; + for (const source of sources) { + await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ); + } + }); + + it("enforces one default export and reports stable source locations", async () => { + await assertEvaluationError( + 'import type { VeryfrontConfig } from "veryfront"; const config = {};', + "invalid-result", + "missing-default-export", + ); + await assertEvaluationError( + "export const config = {}; export default config;", + "unsupported-syntax", + "unsupported-export", + ); + + const duplicate = await assertEvaluationError( + "export default {}; export default {};", + "invalid-result", + "duplicate-default-export", + ); + assertEquals(duplicate.phase, "validate"); + + const located = await assertEvaluationError( + `import { defineConfig } from "veryfront"; +const safe = {}; +export default process.env;`, + "forbidden-capability", + "unsupported-call", + ); + assertEquals(located.location?.line, 3); + assertEquals(located.location?.fileName, "veryfront.config.ts"); + }); + + it("reports only the validated selected config basename", async () => { + for ( + const fileName of [ + "veryfront.config.js", + "veryfront.config.ts", + "veryfront.config.mjs", + ] as const + ) { + const error = await assertRejects( + () => + evaluateDeclarativeConfig({ + source: "export default process.env;", + fileName, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.location?.fileName, fileName); + } + + const invalid = await assertRejects( + () => + evaluateDeclarativeConfig({ + source: "export default {};", + fileName: "/tenant/private/veryfront.config.ts", + environmentName: "production", + environment: {}, + } as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(invalid.code, "input-invalid"); + assertEquals(invalid.reason, "config-file-name"); + assertEquals(invalid.location, null); + }); + + it("counts every ECMAScript line terminator in source locations", async () => { + for ( + const [name, terminator] of [ + ["LF", "\n"], + ["CRLF", "\r\n"], + ["CR", "\r"], + ["LINE SEPARATOR", "\u2028"], + ["PARAGRAPH SEPARATOR", "\u2029"], + ] as const + ) { + const error = await assertEvaluationError( + `${terminator}export default process.env;`, + "forbidden-capability", + "unsupported-call", + ); + assertEquals(error.location?.line, 2, name); + assertEquals(error.location?.column, 15, name); + assertEquals(error.location?.offset, terminator.length + 15, name); + } + }); + + it("rejects invalid helper use and expression-bodied factory violations", async () => { + const sources = [ + `import { defineConfig } from "veryfront"; export default defineConfig();`, + `import { defineConfig } from "veryfront"; export default defineConfig({}, {});`, + `import { getEnv } from "veryfront"; export default { value: getEnv(1) };`, + `import { getEnv } from "veryfront"; const key = "VALUE"; export default { value: getEnv(key) };`, + 'import { getEnv } from "veryfront"; export default { value: getEnv(`VALUE`) };', + `import { mergeConfigs } from "veryfront"; export default mergeConfigs(1);`, + `import { defineConfigWithEnv } from "veryfront"; export default defineConfigWithEnv((env) => { return { title: env }; });`, + `import { defineConfigWithEnv } from "veryfront"; export default defineConfigWithEnv(({ name }) => ({ title: name }));`, + `import { defineConfigWithEnv } from "veryfront"; export default defineConfigWithEnv(async (env) => ({ title: env }));`, + `import { defineConfig } from "veryfront"; const helper = defineConfig; export default {};`, + `const value = {}; export default value();`, + ]; + for (const source of sources) { + await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + environmentName: "production", + environment: {}, + }), + DeclarativeConfigEvaluationError, + ); + } + }); + + it("allows only homogeneous numeric or string addition", async () => { + assertEquals( + await evaluateDeclarativeConfig({ + source: `export default { numeric: 20 + 22, text: "Very" + "front" };`, + environmentName: "production", + environment: {}, + }), + { numeric: 42, text: "Veryfront" }, + ); + await assertEvaluationError( + `export default { title: "port-" + 3000 };`, + "evaluation-type-error", + "operand-type", + ); + }); + + it("rejects cross-runtime nondeterministic exponentiation", async () => { + const error = await assertEvaluationError( + "export default { value: 78.50374 ** 7 };", + "unsupported-syntax", + "unsupported-expression", + ); + assertEquals(error.phase, "validate"); + }); + + it("requires a plain record result and rejects unresolved environment sentinels", async () => { + await assertEvaluationError( + "export default [];", + "invalid-result", + "result-not-record", + ); + await assertEvaluationError( + `import { getEnv } from "veryfront"; export default { title: getEnv("MISSING") };`, + "invalid-result", + "result-not-snapshot-safe", + { environment: {} }, + ); + await assertEvaluationError( + 'import { getEnv } from "veryfront"; export default { title: `${getEnv("MISSING")}` };', + "invalid-result", + "result-not-snapshot-safe", + { environment: {} }, + ); + }); + + it("enforces source, statement, import, binding, and AST limits", async () => { + await assertEvaluationError( + " ".repeat(DECLARATIVE_CONFIG_LIMITS.maxSourceBytes + 1), + "source-too-large", + "source-bytes", + ); + await assertEvaluationError( + "é".repeat(Math.floor(DECLARATIVE_CONFIG_LIMITS.maxSourceBytes / 2) + 1), + "source-too-large", + "source-bytes", + ); + + const declarations = new Array(); + for ( + let index = 0; + index < DECLARATIVE_CONFIG_LIMITS.maxTopLevelStatements; + index += 1 + ) { + declarations.push(`type T${index} = string;`); + } + await assertEvaluationError( + `${declarations.join("")} export default {};`, + "resource-limit-exceeded", + "statement-count", + ); + + await assertEvaluationError( + `import type { A } from "veryfront"; + import type { B } from "veryfront"; + export default {};`, + "resource-limit-exceeded", + "unsupported-import", + ); + + const typeImports = new Array(); + for ( + let index = 0; + index <= DECLARATIVE_CONFIG_LIMITS.maxImportSpecifiers; + index += 1 + ) { + typeImports.push(`T${index}`); + } + await assertEvaluationError( + `import type { ${typeImports.join(",")} } from "veryfront"; export default {};`, + "resource-limit-exceeded", + "arguments", + ); + + const bindings = new Array(); + for ( + let index = 0; + index <= DECLARATIVE_CONFIG_LIMITS.maxBindings; + index += 1 + ) { + bindings.push(`const value${index} = ${index};`); + } + await assertEvaluationError( + `${bindings.join("")} export default {};`, + "resource-limit-exceeded", + "binding-count", + ); + + await assertEvaluationError( + `export default [${repeatedList("0", DECLARATIVE_CONFIG_LIMITS.maxAstNodes)}];`, + "resource-limit-exceeded", + "ast-nodes", + ); + }); + + it("enforces validation depth, evaluation depth, and evaluation-step limits", async () => { + const validationNested = `${"[".repeat(DECLARATIVE_CONFIG_LIMITS.maxValidationDepth + 1)}null${ + "]".repeat(DECLARATIVE_CONFIG_LIMITS.maxValidationDepth + 1) + }`; + await assertEvaluationError( + `export default ${validationNested};`, + "resource-limit-exceeded", + "evaluation-depth", + ); + + const evaluationNested = `${"[".repeat(DECLARATIVE_CONFIG_LIMITS.maxEvaluationDepth + 1)}null${ + "]".repeat(DECLARATIVE_CONFIG_LIMITS.maxEvaluationDepth + 1) + }`; + await assertEvaluationError( + `export default ${evaluationNested};`, + "resource-limit-exceeded", + "evaluation-depth", + ); + + const fields = repeatedList( + "x: 1", + DECLARATIVE_CONFIG_LIMITS.maxObjectProperties, + ).replaceAll("x: 1", (_match, offset) => `x${offset}: 1`); + const stepHeavy = ` + const first = { ${fields} }; + const second = { ${fields} }; + export default { ...first, ...second }; + `; + await assertEvaluationError( + stepHeavy, + "resource-limit-exceeded", + "evaluation-steps", + ); + }); + + it("enforces argument, spread, object, array, key, and template limits", async () => { + const argumentsList = repeatedList( + "{}", + DECLARATIVE_CONFIG_LIMITS.maxArguments + 1, + ); + await assertEvaluationError( + `import { mergeConfigs } from "veryfront"; export default mergeConfigs(${argumentsList});`, + "resource-limit-exceeded", + "arguments", + ); + + const emptySpreads = repeatedList( + "...base", + DECLARATIVE_CONFIG_LIMITS.maxSpreadOperations + 1, + ); + await assertEvaluationError( + `const base = {}; export default { ${emptySpreads} };`, + "resource-limit-exceeded", + "spread-operations", + ); + + const baseFields = new Array(); + for ( + let index = 0; + index < DECLARATIVE_CONFIG_LIMITS.maxObjectProperties; + index += 1 + ) { + baseFields.push(`key${index}: ${index}`); + } + await assertEvaluationError( + `const base = { ${baseFields.join(",")} }; + export default { ...base, ...base, ...base };`, + "resource-limit-exceeded", + "spread-copies", + ); + + await assertEvaluationError( + `export default { ${ + repeatedList( + "value: 1", + DECLARATIVE_CONFIG_LIMITS.maxObjectProperties + 1, + ) + } };`, + "resource-limit-exceeded", + "object-properties", + ); + + await assertEvaluationError( + `export default [${repeatedList("0", DECLARATIVE_CONFIG_LIMITS.maxArrayElements + 1)}];`, + "resource-limit-exceeded", + "array-elements", + ); + + const longKey = "k".repeat( + DECLARATIVE_CONFIG_LIMITS.maxObjectKeyLength + 1, + ); + await assertEvaluationError( + `export default { ${JSON.stringify(longKey)}: true };`, + "resource-limit-exceeded", + "object-key", + ); + + const substitutions = repeatedList( + "${value}", + DECLARATIVE_CONFIG_LIMITS.maxTemplateExpressions + 1, + ); + await assertEvaluationError( + `const value = "x"; export default { title: \`${substitutions}\` };`, + "resource-limit-exceeded", + "template-expressions", + ); + }); + + it("enforces intermediate-string and tenant-environment limits", async () => { + const stringFields = new Array(); + for (let index = 0; index < 65; index += 1) { + stringFields.push(`key${index}: getEnv("BIG")`); + } + await assertEvaluationError( + `import { getEnv } from "veryfront"; export default { ${stringFields.join(",")} };`, + "resource-limit-exceeded", + "intermediate-string", + { + environment: { + BIG: "v".repeat(16_384), + }, + }, + ); + + const longestEnvironmentName = "e".repeat( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentNameLength, + ); + assertEquals( + await evaluateDeclarativeConfig({ + source: + `import { defineConfigWithEnv } from "veryfront"; export default defineConfigWithEnv((name) => ({ title: name }));`, + environmentName: longestEnvironmentName, + environment: {}, + }), + { title: longestEnvironmentName }, + ); + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-name", + { + environmentName: "e".repeat( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentNameLength + 1, + ), + }, + ); + + const tooManyEntries = Object.create(null) as Record; + for ( + let index = 0; + index <= DECLARATIVE_CONFIG_LIMITS.maxEnvironmentEntries; + index += 1 + ) { + tooManyEntries[`KEY_${index}`] = "value"; + } + await assertEvaluationError( + "export default {};", + "resource-limit-exceeded", + "environment-entries", + { environment: tooManyEntries }, + ); + + const longEnvironmentKey = Object.create(null) as Record; + longEnvironmentKey[ + `K${"E".repeat(DECLARATIVE_CONFIG_LIMITS.maxEnvironmentKeyLength)}` + ] = "value"; + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-key", + { environment: longEnvironmentKey }, + ); + + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-value", + { + environment: { + VALUE: "v".repeat( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentValueLength + 1, + ), + }, + }, + ); + + for (const key of ["bad\u0000key", "bad=key"]) { + const invalidEnvironment = Object.create(null) as Record; + Object.defineProperty(invalidEnvironment, key, { + value: "value", + enumerable: true, + }); + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-key", + { environment: invalidEnvironment }, + ); + } + + await assertEvaluationError( + "export default {};", + "input-invalid", + "environment-value", + { environment: { VALUE: "bad\u0000value" } }, + ); + + const formerlyDangerousEnvironment = Object.create(null) as Record; + for (const key of ["__proto__", "constructor", "prototype"]) { + Object.defineProperty(formerlyDangerousEnvironment, key, { + value: key, + enumerable: true, + }); + } + assertEquals( + await evaluateDeclarativeConfig({ + source: `import { getEnv } from "veryfront"; export default { + first: getEnv("__proto__"), + second: getEnv("constructor"), + third: getEnv("prototype"), + };`, + environmentName: "production", + environment: formerlyDangerousEnvironment, + }), + { + first: "__proto__", + second: "constructor", + third: "prototype", + }, + ); + + const byteHeavyEnvironment = Object.create(null) as Record; + byteHeavyEnvironment.FIRST = "v".repeat( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentBytes / 2, + ); + byteHeavyEnvironment.SECOND = "v".repeat( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentBytes / 2, + ); + await assertEvaluationError( + "export default {};", + "resource-limit-exceeded", + "environment-bytes", + { environment: byteHeavyEnvironment }, + ); + }); + + it("prepares opaque, stable, insertion-order-independent context identities", async () => { + const firstEnvironment = Object.create(null) as Record; + firstEnvironment.SECRET = "tenant-secret"; + firstEnvironment.REGION = "eu"; + const secondEnvironment = Object.create(null) as Record; + secondEnvironment.REGION = "eu"; + secondEnvironment.SECRET = "tenant-secret"; + + const first = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: firstEnvironment, + }); + const repeated = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: firstEnvironment, + }); + const reordered = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: secondEnvironment, + }); + const changedName = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: firstEnvironment, + }); + const changedValue = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { REGION: "us", SECRET: "tenant-secret" }, + }); + const framedLeft = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { a: "bc" }, + }); + const framedRight = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { ab: "c" }, + }); + const surrogateLeft = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { VALUE: "\ud800" }, + }); + const surrogateRight = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: { VALUE: "\ud801" }, + }); + + assertEquals(first.cacheFingerprint, repeated.cacheFingerprint); + assertEquals(first.cacheFingerprint, reordered.cacheFingerprint); + assertNotEquals(first.cacheFingerprint, changedName.cacheFingerprint); + assertNotEquals(first.cacheFingerprint, changedValue.cacheFingerprint); + assertNotEquals(framedLeft.cacheFingerprint, framedRight.cacheFingerprint); + assertNotEquals( + surrogateLeft.cacheFingerprint, + surrogateRight.cacheFingerprint, + ); + assertMatch(first.cacheFingerprint, /^ctx1:[0-9a-f]{64}$/); + assertEquals(Object.getPrototypeOf(first), null); + assertEquals(Object.isFrozen(first), true); + assertEquals(Reflect.ownKeys(first), ["cacheFingerprint"]); + assertEquals(JSON.stringify(first).includes("tenant-secret"), false); + }); + + it("reuses a prepared snapshot without re-reading the original environment", async () => { + const environment = { TENANT: "original" }; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "production", + environment, + }); + environment.TENANT = "mutated"; + + const source = `import { getEnv } from "veryfront"; + export default { title: getEnv("TENANT") ?? "missing" };`; + const [first, second] = await Promise.all([ + evaluateDeclarativeConfig({ source, preparedContext }), + evaluateDeclarativeConfig({ source, preparedContext }), + ]); + assertEquals(first, { title: "original" }); + assertEquals(second, { title: "original" }); + + const workerPayload = createPreparedDeclarativeConfigWorkerPayload( + source, + preparedContext, + ); + assertEquals( + workerPayload.cacheFingerprint, + preparedContext.cacheFingerprint, + ); + assertEquals(workerPayload.policyVersion, "hosted-declarative-config-v2"); + assertEquals(Object.getPrototypeOf(workerPayload), null); + assertEquals(Object.isFrozen(workerPayload), true); + assertEquals(Object.isFrozen(workerPayload.evaluationOptions), true); + assertEquals( + workerPayload.evaluationOptions.fileName, + "veryfront.config.ts", + ); + assertEquals( + await evaluateDeclarativeConfig(workerPayload.evaluationOptions), + { title: "original" }, + ); + + const forged = Object.freeze({ + cacheFingerprint: preparedContext.cacheFingerprint, + }); + const forgedError = await assertRejects( + () => evaluateDeclarativeConfig({ source, preparedContext: forged }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(forgedError.code, "input-invalid"); + assertEquals(forgedError.reason, "prepared-context"); + + const ambiguousError = await assertRejects( + () => + evaluateDeclarativeConfig({ + source, + preparedContext, + environmentName: "production", + environment: {}, + } as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(ambiguousError.code, "input-invalid"); + assertEquals(ambiguousError.reason, "prepared-context"); + }); + + it("rejects environment getters during preparation without invoking them", async () => { + let getterCalls = 0; + const environment = Object.create(null) as Record; + Object.defineProperty(environment, "SECRET", { + enumerable: true, + get() { + getterCalls += 1; + return "not-read"; + }, + }); + const error = await assertRejects( + () => + prepareDeclarativeConfigContext({ + environmentName: "production", + environment, + }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, "input-invalid"); + assertEquals(error.reason, "environment-accessor"); + assertEquals(getterCalls, 0); + }); + + it("captures evaluation option data descriptors without invoking getters", async () => { + for ( + const key of [ + "source", + "fileName", + "environmentName", + "environment", + "preparedContext", + ] as const + ) { + let getterCalls = 0; + const options: Record = { + source: "export default {};", + environmentName: "production", + environment: {}, + }; + if (key === "preparedContext") { + delete options.environmentName; + delete options.environment; + } + Object.defineProperty(options, key, { + enumerable: true, + get() { + getterCalls += 1; + return undefined; + }, + }); + + const error = await assertRejects( + () => evaluateDeclarativeConfig(options as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, "input-invalid", key); + assertEquals(error.reason, "options-accessor", key); + assertEquals(getterCalls, 0, key); + } + }); + + it("hardens preparation options and normalizes reflection failures", async () => { + let getterCalls = 0; + const accessorOptions = { + environmentName: "production", + environment: {}, + }; + Object.defineProperty(accessorOptions, "environment", { + enumerable: true, + get() { + getterCalls += 1; + return {}; + }, + }); + const accessorError = await assertRejects( + () => prepareDeclarativeConfigContext(accessorOptions), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(accessorError.reason, "options-accessor"); + assertEquals(getterCalls, 0); + + const nonEnumerable = Object.create(null) as Record; + Object.defineProperty(nonEnumerable, "source", { + value: "export default {};", + enumerable: false, + }); + Object.defineProperty(nonEnumerable, "environmentName", { + value: "production", + enumerable: true, + }); + Object.defineProperty(nonEnumerable, "environment", { + value: {}, + enumerable: true, + }); + const nonEnumerableError = await assertRejects( + () => evaluateDeclarativeConfig(nonEnumerable as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(nonEnumerableError.reason, "options-accessor"); + + const trapped = new Proxy({}, { + getPrototypeOf() { + throw new Error("not exposed"); + }, + }); + const trapError = await assertRejects( + () => evaluateDeclarativeConfig(trapped as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(trapError.reason, "options-prototype"); + + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + const revokedError = await assertRejects( + () => evaluateDeclarativeConfig(proxy as never), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(revokedError.reason, "options-prototype"); + }); + + it("descriptor-validates the injected worker parser without invoking getters", async () => { + let getterCalls = 0; + const parser = {}; + Object.defineProperty(parser, "parse", { + enumerable: true, + get() { + getterCalls += 1; + return () => ({}); + }, + }); + const error = await assertRejects( + () => + evaluateDeclarativeConfigWithParser( + { + source: "export default {};", + environmentName: "production", + environment: {}, + }, + parser, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, "parser-contract-violation"); + assertEquals(error.reason, "parser-shape"); + assertEquals(getterCalls, 0); + }); + + it("normalizes malformed injected parser results", async () => { + for ( + const malformedResult of [ + null, + new Proxy({}, { + getOwnPropertyDescriptor() { + throw new Error("not exposed"); + }, + }), + ] + ) { + const error = await assertRejects( + () => + evaluateDeclarativeConfigWithParser( + { + source: "export default {};", + environmentName: "production", + environment: {}, + }, + { + parse: async () => malformedResult, + }, + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.code, "parser-contract-violation"); + assertEquals(error.phase, "validate"); + assertEquals(error.reason, "ast-shape"); + } + }); + + it("imports without global SubtleCrypto and fails closed when Web Crypto is absent", async () => { + const evaluatorUrl = new URL( + "./declarative-evaluator.ts?crypto-unavailable-test", + import.meta.url, + ).href; + const result = await runPermissionlessWorker(` + Object.defineProperty(globalThis, "crypto", { + value: undefined, + configurable: true, + }); + const { prepareDeclarativeConfigContext } = await import(${JSON.stringify(evaluatorUrl)}); + try { + await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: {}, + }); + globalThis.postMessage({ accepted: true }); + } catch (error) { + globalThis.postMessage({ + accepted: false, + name: error?.name, + code: error?.code, + phase: error?.phase, + reason: error?.reason, + retryable: error?.retryable, + }); + } + `); + assertEquals(result, { + accepted: false, + name: "DeclarativeConfigEvaluationError", + code: "evaluator-unavailable", + phase: "input", + reason: "crypto-unavailable", + retryable: true, + }); + }); + + it("maps every tenant environment limit to the platform contract", () => { + assertEquals( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentEntries, + PROJECT_ENV_SNAPSHOT_LIMITS.maxEntries, + ); + assertEquals( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentKeyLength, + PROJECT_ENV_SNAPSHOT_LIMITS.maxKeyChars, + ); + assertEquals( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentValueLength, + PROJECT_ENV_SNAPSHOT_LIMITS.maxValueChars, + ); + assertEquals( + DECLARATIVE_CONFIG_LIMITS.maxEnvironmentBytes, + PROJECT_ENV_SNAPSHOT_LIMITS.maxUtf8Bytes, + ); + }); + + it("keeps the evaluator security policy immutable", () => { + assertEquals(Object.isFrozen(DECLARATIVE_CONFIG_LIMITS), true); + assertThrows( + () => + Object.defineProperty(DECLARATIVE_CONFIG_LIMITS, "maxSourceBytes", { + value: Number.MAX_SAFE_INTEGER, + }), + TypeError, + ); + }); +}); diff --git a/src/config/declarative-evaluator.ts b/src/config/declarative-evaluator.ts new file mode 100644 index 0000000000..c0838dd268 --- /dev/null +++ b/src/config/declarative-evaluator.ts @@ -0,0 +1,3711 @@ +/** + * Non-executing evaluator for hosted Veryfront configuration source. + * + * The project source is parsed by the pinned first-party Babel parser and then + * interpreted as a deliberately small expression language. It is never + * imported, evaluated, generated, or passed to a JavaScript runtime. + * + * @module + */ + +import type { ASTNode, CodeParser } from "#veryfront/extensions/parser/index.ts"; +import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; +import { + canonicalizeConfigSnapshot, + CONFIG_SNAPSHOT_LIMITS, + ConfigSnapshotError, + type ConfigSnapshotRecord, + type ConfigSnapshotValue, +} from "./snapshot.ts"; +import { + MAX_CORS_ORIGIN_COUNT, + MAX_CORS_ORIGIN_LENGTH, + MAX_CORS_ORIGIN_LIST_LENGTH, +} from "#veryfront/utils/cors-policy-limits.ts"; +import { PROJECT_ENV_SNAPSHOT_LIMITS } from "#veryfront/platform/compat/process/project-env-contract.ts"; +import { MAX_HOSTED_RENDER_CACHE_ENTRIES } from "./defaults.ts"; + +const IntrinsicArray = Array; +const IntrinsicUint8Array = Uint8Array; +const IntrinsicWeakMap = WeakMap; +const IntrinsicWeakSet = WeakSet; +const ArrayIsArray = Array.isArray; +const ArrayPrototypeSort = Array.prototype.sort; +const NumberIsFinite = Number.isFinite; +const NumberIsInteger = Number.isInteger; +const ObjectCreate = Object.create; +const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectPrototype = Object.prototype; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectOwnKeys = Reflect.ownKeys; +const StringPrototypeCharAt = String.prototype.charAt; +const StringPrototypeCharCodeAt = String.prototype.charCodeAt; +const StringPrototypeTrim = String.prototype.trim; +const WeakMapPrototypeGet = WeakMap.prototype.get; +const WeakMapPrototypeSet = WeakMap.prototype.set; +const WeakSetPrototypeAdd = WeakSet.prototype.add; +const WeakSetPrototypeHas = WeakSet.prototype.has; + +interface CapturedSubtleCrypto { + readonly receiver: object; + readonly generateKey: (...args: never[]) => unknown; + readonly sign: (...args: never[]) => unknown; +} + +const intrinsicSubtleCrypto = captureSubtleCrypto(); + +const AST_SCAN_ENTRY_LIMIT = 131_072; +/** Basenames accepted at the hosted configuration trust boundary. */ +export type DeclarativeConfigFileName = + | "veryfront.config.js" + | "veryfront.config.ts" + | "veryfront.config.mjs"; +/** Backwards-compatible default when a direct evaluator caller omits a name. */ +export const DECLARATIVE_CONFIG_FILE_NAME: DeclarativeConfigFileName = "veryfront.config.ts"; + +export function isDeclarativeConfigFileName( + value: unknown, +): value is DeclarativeConfigFileName { + return value === "veryfront.config.js" || + value === "veryfront.config.ts" || + value === "veryfront.config.mjs"; +} + +const FIRST_PARTY_PARSER_DIRECTORY = "ext-parser-babel"; +const FIRST_PARTY_PARSER_PACKAGE = "@veryfront/ext-parser-babel"; +const FIRST_PARTY_PARSER_SOURCE_ENTRY = "parser-only"; +const FIRST_PARTY_PARSER_PACKAGE_SUBPATH = "parser-only"; +const FINGERPRINT_NAMESPACE = "veryfront-declarative-context-v1"; +const FINGERPRINT_PREFIX = "ctx1:"; +const HEX_DIGITS = "0123456789abcdef"; +const MAX_HOSTED_EXTENSION_NAME_LENGTH = 256; +const POLICY_VERSION = "hosted-declarative-config-v2"; + +type TrustedCodeParser = Pick; + +type TrustedParserModule = Readonly<{ + BabelParseOnlyParser: new () => TrustedCodeParser; +}>; + +/** + * Fixed limits for parsing and interpreting hosted configuration source. + * + * A one-shot worker supplies the killable wall-clock boundary because Babel + * parsing is synchronous. Deployments that require hard memory isolation must + * additionally use a subprocess or container boundary. + */ +export interface DeclarativeConfigLimits { + readonly maxSourceBytes: number; + readonly maxTopLevelStatements: number; + readonly maxImports: number; + readonly maxImportSpecifiers: number; + readonly maxBindings: number; + readonly maxAstNodes: number; + readonly maxValidationDepth: number; + readonly maxEvaluationSteps: number; + readonly maxEvaluationDepth: number; + readonly maxArguments: number; + readonly maxSpreadOperations: number; + readonly maxSpreadCopies: number; + readonly maxObjectProperties: number; + readonly maxObjectKeyLength: number; + readonly maxArrayElements: number; + readonly maxTemplateExpressions: number; + readonly maxIntermediateStringUnits: number; + readonly maxEnvironmentNameLength: number; + readonly maxEnvironmentEntries: number; + readonly maxEnvironmentKeyLength: number; + readonly maxEnvironmentValueLength: number; + readonly maxEnvironmentBytes: number; +} + +/** Limits enforced before and during every evaluation. */ +export const DECLARATIVE_CONFIG_LIMITS: Readonly = ObjectFreeze({ + maxSourceBytes: 65_536, + maxTopLevelStatements: 256, + maxImports: 1, + maxImportSpecifiers: 8, + maxBindings: 128, + maxAstNodes: 4_096, + maxValidationDepth: 64, + maxEvaluationSteps: 3_000, + maxEvaluationDepth: 32, + maxArguments: 16, + maxSpreadOperations: 64, + maxSpreadCopies: 1_024, + maxObjectProperties: 512, + maxObjectKeyLength: 256, + maxArrayElements: 1_024, + maxTemplateExpressions: 64, + maxIntermediateStringUnits: 1_048_576, + maxEnvironmentNameLength: 255, + maxEnvironmentEntries: PROJECT_ENV_SNAPSHOT_LIMITS.maxEntries, + maxEnvironmentKeyLength: PROJECT_ENV_SNAPSHOT_LIMITS.maxKeyChars, + maxEnvironmentValueLength: PROJECT_ENV_SNAPSHOT_LIMITS.maxValueChars, + maxEnvironmentBytes: PROJECT_ENV_SNAPSHOT_LIMITS.maxUtf8Bytes, +}); + +/** Cache/policy identity for this exact declarative language. */ +export const DECLARATIVE_CONFIG_POLICY_VERSION = POLICY_VERSION; + +export type DeclarativeConfigErrorCode = + | "evaluation-type-error" + | "evaluator-unavailable" + | "forbidden-capability" + | "input-invalid" + | "invalid-binding" + | "invalid-helper-usage" + | "invalid-result" + | "non-finite-number" + | "parser-contract-violation" + | "parser-unavailable" + | "resource-limit-exceeded" + | "source-too-large" + | "syntax-error" + | "unsupported-hosted-feature" + | "unsupported-syntax"; + +export type DeclarativeConfigErrorPhase = + | "input" + | "parse" + | "validate" + | "evaluate" + | "result" + | "worker"; + +export interface DeclarativeConfigSourceLocation { + /** One-based line number. */ + readonly line: number; + /** Zero-based column number. */ + readonly column: number; + /** Zero-based UTF-16 source offset. */ + readonly offset: number; + readonly fileName: DeclarativeConfigFileName; +} + +/** Stable details suitable for policy decisions without exposing source text. */ +export type DeclarativeConfigErrorReason = + | "arguments" + | "array-elements" + | "ast-nodes" + | "ast-shape" + | "binding-count" + | "config-file-name" + | "dangerous-key" + | "duplicate-binding" + | "duplicate-default-export" + | "duplicate-key" + | "environment-accessor" + | "environment-bytes" + | "environment-entries" + | "environment-key" + | "environment-name" + | "environment-prototype" + | "environment-symbol" + | "environment-value" + | "evaluation-depth" + | "evaluation-steps" + | "function-value" + | "helper-arguments" + | "helper-as-value" + | "host-global" + | "hosted-bundle-manifest-backend" + | "hosted-cache-directory" + | "hosted-cache-option" + | "hosted-cors-origin" + | "hosted-custom-middleware" + | "hosted-extensions" + | "hosted-render-cache-backend" + | "hosted-render-cache-capacity" + | "import-form" + | "intermediate-string" + | "missing-default-export" + | "non-finite-result" + | "object-key" + | "object-properties" + | "operand-type" + | "options-accessor" + | "options-prototype" + | "parser-load" + | "parser-shape" + | "prepared-context" + | "result-not-record" + | "result-not-snapshot-safe" + | "source-bytes" + | "spread-copies" + | "spread-operations" + | "statement-count" + | "syntax-error" + | "template-expressions" + | "unbound-identifier" + | "crypto-unavailable" + | "unsupported-call" + | "unsupported-export" + | "unsupported-expression" + | "unsupported-import" + | "unsupported-statement" + | "worker-aborted" + | "worker-overloaded" + | "worker-protocol" + | "worker-timeout" + | "worker-unavailable"; + +/** Typed failure emitted for every rejected source or bounded-resource case. */ +export class DeclarativeConfigEvaluationError extends Error { + readonly code: DeclarativeConfigErrorCode; + readonly phase: DeclarativeConfigErrorPhase; + readonly reason: DeclarativeConfigErrorReason; + readonly location: DeclarativeConfigSourceLocation | null; + readonly retryable: boolean; + + constructor(options: { + code: DeclarativeConfigErrorCode; + phase: DeclarativeConfigErrorPhase; + reason: DeclarativeConfigErrorReason; + location?: DeclarativeConfigSourceLocation | null; + retryable?: boolean; + }) { + super( + `Hosted configuration rejected (${options.code}: ${options.reason})`, + ); + this.name = "DeclarativeConfigEvaluationError"; + this.code = options.code; + this.phase = options.phase; + this.reason = options.reason; + this.location = options.location ?? null; + this.retryable = options.retryable ?? false; + } +} + +/** Explicit environment data used to prepare a hosted evaluation context. */ +export interface PrepareDeclarativeConfigContextOptions { + readonly environmentName: string; + /** + * Tenant environment decoded outside the project source. Only own, + * enumerable string data properties are accepted. + * + * The value must originate at a non-executable decoding boundary. + * Same-realm proxies are outside this API's threat model because JavaScript + * cannot reliably identify them before a reflection trap runs. + */ + readonly environment: unknown; +} + +/** + * Opaque, same-isolate context token for repeat hosted evaluations. + * + * The fingerprint is process-local and intentionally cannot be persisted as a + * distributed cache identity. Cache callers must also bind the source digest. + */ +export interface PreparedDeclarativeConfigContext { + readonly cacheFingerprint: string; +} + +/** + * Coupled cache identity and direct worker input derived from one prepared + * snapshot. This DTO is intended only for a trusted structured-clone boundary. + */ +export interface PreparedDeclarativeConfigWorkerEvaluationOptions + extends PrepareDeclarativeConfigContextOptions { + readonly source: string; + readonly fileName: DeclarativeConfigFileName; + readonly preparedContext?: never; +} + +export interface PreparedDeclarativeConfigWorkerPayload { + readonly cacheFingerprint: string; + readonly policyVersion: typeof DECLARATIVE_CONFIG_POLICY_VERSION; + readonly evaluationOptions: PreparedDeclarativeConfigWorkerEvaluationOptions; +} + +/** Hosted evaluation with direct environment input. */ +export interface DirectDeclarativeConfigEvaluationOptions + extends PrepareDeclarativeConfigContextOptions { + readonly source: string; + readonly fileName?: DeclarativeConfigFileName; + readonly preparedContext?: never; +} + +/** Hosted evaluation reusing a previously prepared context. */ +export interface PreparedDeclarativeConfigEvaluationOptions { + readonly source: string; + readonly fileName?: DeclarativeConfigFileName; + readonly preparedContext: PreparedDeclarativeConfigContext; + readonly environmentName?: never; + readonly environment?: never; +} + +/** Explicit data supplied to the hosted evaluator. */ +export type DeclarativeConfigEvaluationOptions = + | DirectDeclarativeConfigEvaluationOptions + | PreparedDeclarativeConfigEvaluationOptions; + +type HelperName = + | "defineConfig" + | "defineConfigWithEnv" + | "getEnv" + | "mergeConfigs"; + +type RuntimePrimitive = null | boolean | number | string | undefined; + +interface RuntimeRecord { + readonly [key: string]: RuntimeValue; +} + +type RuntimeValue = RuntimePrimitive | RuntimeRecord | readonly RuntimeValue[]; + +type Binding = + | Readonly<{ kind: "helper"; helper: HelperName }> + | Readonly<{ kind: "value"; value: RuntimeValue }>; + +interface LexicalEnvironment { + readonly bindings: Record; + readonly parent: LexicalEnvironment | null; +} + +interface EvaluationContext { + readonly source: string; + readonly fileName: DeclarativeConfigFileName; + readonly tenantEnvironment: Readonly>; + readonly environmentName: string; + bindingCount: number; + evaluationSteps: number; + spreadOperations: number; + spreadCopies: number; + intermediateStringUnits: number; +} + +interface PreparedContextState { + readonly tenantEnvironment: Readonly>; + readonly environmentName: string; +} + +interface CapturedOption { + readonly present: boolean; + readonly value: unknown; +} + +interface CapturedEvaluationOptions { + readonly source: CapturedOption; + readonly fileName: CapturedOption; + readonly environmentName: CapturedOption; + readonly environment: CapturedOption; + readonly preparedContext: CapturedOption; +} + +let trustedParserPromise: Promise | undefined; +let fingerprintKeyPromise: Promise | undefined; +const preparedContextStates = new IntrinsicWeakMap(); + +function throwEvaluationError( + code: DeclarativeConfigErrorCode, + phase: DeclarativeConfigErrorPhase, + reason: DeclarativeConfigErrorReason, + context?: EvaluationContext, + node?: ASTNode, +): never { + throw new DeclarativeConfigEvaluationError({ + code, + phase, + reason, + location: context && node ? sourceLocation(context.source, node.start, context.fileName) : null, + }); +} + +function sourceLocation( + source: string, + offsetValue: unknown, + fileName: DeclarativeConfigFileName, +): DeclarativeConfigSourceLocation | null { + if ( + typeof offsetValue !== "number" || + !NumberIsInteger(offsetValue) || + offsetValue < 0 || + offsetValue > source.length + ) { + return null; + } + + let line = 1; + let column = 0; + for (let index = 0; index < offsetValue; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, source, [index]) as number; + if (code === 13) { + line += 1; + column = 0; + if ( + index + 1 < offsetValue && + (ReflectApply(StringPrototypeCharCodeAt, source, [index + 1]) as number) === 10 + ) { + index += 1; + } + } else if (code === 10 || code === 0x2028 || code === 0x2029) { + line += 1; + column = 0; + } else { + column += 1; + } + } + + return ObjectFreeze({ + line, + column, + offset: offsetValue, + fileName, + }); +} + +function isAstNode(value: unknown): value is ASTNode { + if (typeof value !== "object" || value === null) return false; + try { + const descriptor = ObjectGetOwnPropertyDescriptor(value, "type"); + return descriptor !== undefined && + hasOwn(descriptor, "value") && + typeof descriptor.value === "string"; + } catch { + return false; + } +} + +function requireAstNode( + value: unknown, + context: EvaluationContext, + parent: ASTNode, +): ASTNode { + if (!isAstNode(value)) { + return throwEvaluationError( + "parser-contract-violation", + "validate", + "ast-shape", + context, + parent, + ); + } + return value; +} + +function requireNodeArray( + value: unknown, + context: EvaluationContext, + parent: ASTNode, +): unknown[] { + if (!ArrayIsArray(value)) { + return throwEvaluationError( + "parser-contract-violation", + "validate", + "ast-shape", + context, + parent, + ); + } + return value; +} + +function findCallableDataProperty( + receiver: object, + property: PropertyKey, +): ((...args: never[]) => unknown) | undefined { + let current: object | null = receiver; + let depth = 0; + while (current !== null && depth < 16) { + const descriptor = ObjectGetOwnPropertyDescriptor(current, property); + if (descriptor !== undefined) { + return hasOwn(descriptor, "value") && + typeof descriptor.value === "function" + ? descriptor.value as (...args: never[]) => unknown + : undefined; + } + current = ObjectGetPrototypeOf(current); + depth += 1; + } + return undefined; +} + +function captureSubtleCrypto(): CapturedSubtleCrypto | undefined { + try { + const cryptoValue: unknown = globalThis.crypto; + if (typeof cryptoValue !== "object" || cryptoValue === null) { + return undefined; + } + const subtleValue: unknown = (cryptoValue as Crypto).subtle; + if (typeof subtleValue !== "object" || subtleValue === null) { + return undefined; + } + const generateKey = findCallableDataProperty(subtleValue, "generateKey"); + const sign = findCallableDataProperty(subtleValue, "sign"); + if (!generateKey || !sign) return undefined; + return ObjectFreeze({ + receiver: subtleValue, + generateKey, + sign, + }); + } catch { + return undefined; + } +} + +function hasOwn(target: object, key: PropertyKey): boolean { + return ReflectApply(ObjectPrototypeHasOwnProperty, target, [key]) as boolean; +} + +function defineDataProperty( + target: object, + key: PropertyKey, + value: unknown, + enumerable: boolean, + writable: boolean, + configurable: boolean, +): void { + const descriptor = ObjectCreate(null) as PropertyDescriptor; + descriptor.value = value; + descriptor.enumerable = enumerable; + descriptor.writable = writable; + descriptor.configurable = configurable; + ObjectDefineProperty(target, key, descriptor); +} + +function weakSetHas(seen: WeakSet, value: object): boolean { + return ReflectApply(WeakSetPrototypeHas, seen, [value]) as boolean; +} + +function weakSetAdd(seen: WeakSet, value: object): void { + ReflectApply(WeakSetPrototypeAdd, seen, [value]); +} + +function pushValue(array: T[], value: T): void { + defineDataProperty(array, array.length, value, true, true, true); +} + +async function loadTrustedParser(): Promise { + try { + const parserModule = await importFirstPartyExtensionModule( + FIRST_PARTY_PARSER_DIRECTORY, + FIRST_PARTY_PARSER_PACKAGE, + { + sourceEntry: FIRST_PARTY_PARSER_SOURCE_ENTRY, + packageSubpath: FIRST_PARTY_PARSER_PACKAGE_SUBPATH, + }, + ); + if (typeof parserModule.BabelParseOnlyParser !== "function") throw new TypeError(); + return captureTrustedParser(new parserModule.BabelParseOnlyParser()); + } catch { + throw new DeclarativeConfigEvaluationError({ + code: "parser-unavailable", + phase: "parse", + reason: "parser-load", + retryable: true, + }); + } +} + +function captureTrustedParser(parser: unknown): TrustedCodeParser { + if (typeof parser !== "object" || parser === null) { + return throwParserShapeError(); + } + let parse: ((...args: never[]) => unknown) | undefined; + try { + parse = findCallableDataProperty(parser, "parse"); + } catch { + return throwParserShapeError(); + } + if (!parse) return throwParserShapeError(); + const parseMethod = parse; + + const captured = ObjectCreate(null) as TrustedCodeParser; + defineDataProperty( + captured, + "parse", + (options: Parameters[0]) => + ReflectApply(parseMethod, parser, [options]) as Promise, + true, + false, + false, + ); + return ObjectFreeze(captured); +} + +function throwParserShapeError(): never { + throw new DeclarativeConfigEvaluationError({ + code: "parser-contract-violation", + phase: "input", + reason: "parser-shape", + }); +} + +async function getTrustedParser(): Promise { + const pending = trustedParserPromise ??= loadTrustedParser(); + try { + return await pending; + } catch (error) { + if (trustedParserPromise === pending) trustedParserPromise = undefined; + throw error; + } +} + +function countUtf8BytesUpTo(value: string, maximum: number): number { + if (value.length > maximum) { + return maximum + 1; + } + + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number; + let additionalBytes: number; + if (code <= 0x7f) { + additionalBytes = 1; + } else if (code <= 0x7ff) { + additionalBytes = 2; + } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const next = ReflectApply(StringPrototypeCharCodeAt, value, [index + 1]) as number; + if (next >= 0xdc00 && next <= 0xdfff) { + additionalBytes = 4; + index += 1; + } else { + additionalBytes = 3; + } + } else { + additionalBytes = 3; + } + if (additionalBytes > maximum - bytes) { + return maximum + 1; + } + bytes += additionalBytes; + } + return bytes; +} + +function countUtf8BytesBounded(source: string): number { + return countUtf8BytesUpTo( + source, + DECLARATIVE_CONFIG_LIMITS.maxSourceBytes, + ); +} + +function parseErrorLocation( + source: string, + error: unknown, + fileName: DeclarativeConfigFileName, +): DeclarativeConfigSourceLocation | null { + const position = typeof error === "object" && error !== null + ? (error as Record).pos + : undefined; + return sourceLocation(source, position, fileName); +} + +function parserErrorReason(error: unknown): DeclarativeConfigErrorReason { + const reasonCode = typeof error === "object" && error !== null + ? (error as Record).reasonCode + : undefined; + if (reasonCode === "DuplicateDefaultExport") return "duplicate-default-export"; + if (reasonCode === "VarRedeclaration") return "duplicate-binding"; + return "syntax-error"; +} + +function preflightAst( + ast: ASTNode, + source: string, + fileName: DeclarativeConfigFileName, +): void { + const stack: unknown[] = [ast]; + const seen = new IntrinsicWeakSet(); + let cursor = 0; + let astNodes = 0; + let scannedEntries = 0; + + while (cursor < stack.length) { + const value = stack[cursor]; + cursor += 1; + if (typeof value !== "object" || value === null) continue; + if (weakSetHas(seen, value)) continue; + weakSetAdd(seen, value); + + if (ArrayIsArray(value)) { + scannedEntries += value.length; + if (scannedEntries > AST_SCAN_ENTRY_LIMIT) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "validate", + reason: "ast-nodes", + }); + } + for (let index = 0; index < value.length; index += 1) { + pushValue(stack, value[index]); + } + continue; + } + + const record = value as Record; + if (typeof record.type === "string") { + astNodes += 1; + if (astNodes > DECLARATIVE_CONFIG_LIMITS.maxAstNodes) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "validate", + reason: "ast-nodes", + location: sourceLocation(source, record.start, fileName), + }); + } + } + + const keys = ReflectOwnKeys(record); + scannedEntries += keys.length; + if (scannedEntries > AST_SCAN_ENTRY_LIMIT) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "validate", + reason: "ast-nodes", + location: sourceLocation(source, record.start, fileName), + }); + } + for (let index = 0; index < keys.length; index += 1) { + const descriptor = ObjectGetOwnPropertyDescriptor(record, keys[index]!); + if (descriptor && hasOwn(descriptor, "value")) { + pushValue(stack, descriptor.value); + } + } + } +} + +function extractProgram( + ast: ASTNode, + source: string, + fileName: DeclarativeConfigFileName, +): ASTNode { + if (ast.type !== "File" || !isAstNode(ast.program) || ast.program.type !== "Program") { + throw new DeclarativeConfigEvaluationError({ + code: "parser-contract-violation", + phase: "validate", + reason: "ast-shape", + location: sourceLocation(source, ast.start, fileName), + }); + } + const program = ast.program; + if ( + program.sourceType !== "module" || + program.interpreter !== null || + !ArrayIsArray(program.directives) || + program.directives.length !== 0 || + !ArrayIsArray(program.body) + ) { + throw new DeclarativeConfigEvaluationError({ + code: "unsupported-syntax", + phase: "validate", + reason: "unsupported-statement", + location: sourceLocation(source, program.start, fileName), + }); + } + return program; +} + +function isEnvironmentKey(key: string): boolean { + if (key.length === 0) return false; + for (let index = 0; index < key.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, key, [index]) as number; + if (code === 0 || code === 61) return false; + } + return true; +} + +function isEnvironmentValue(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if ((ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number) === 0) { + return false; + } + } + return true; +} + +function validateEnvironmentName(name: unknown): string { + if ( + typeof name !== "string" || + name.length === 0 || + name.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentNameLength + ) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-name", + }); + } + for (let index = 0; index < name.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, name, [index]) as number; + if (code < 32 || code === 127) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-name", + }); + } + } + return name; +} + +function snapshotTenantEnvironment( + input: unknown, +): Readonly> { + if (typeof input !== "object" || input === null) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + let inputIsArray: boolean; + try { + inputIsArray = ArrayIsArray(input); + } catch { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + if (inputIsArray) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + + let prototype: object | null; + try { + prototype = ObjectGetPrototypeOf(input); + } catch { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + if (prototype !== null && prototype !== ObjectPrototype) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + + let keys: PropertyKey[]; + try { + keys = ReflectOwnKeys(input); + } catch { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-prototype", + }); + } + if (keys.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentEntries) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "input", + reason: "environment-entries", + }); + } + ReflectApply(ArrayPrototypeSort, keys, [ + (left: PropertyKey, right: PropertyKey) => + typeof left === "string" && typeof right === "string" + ? left < right ? -1 : left > right ? 1 : 0 + : typeof left === "string" + ? -1 + : typeof right === "string" + ? 1 + : 0, + ]); + + const output = ObjectCreate(null) as Record; + let totalBytes = 0; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + if (typeof key !== "string") { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-symbol", + }); + } + if ( + key.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentKeyLength || + !isEnvironmentKey(key) + ) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-key", + }); + } + + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ObjectGetOwnPropertyDescriptor(input, key); + } catch { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-accessor", + }); + } + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-accessor", + }); + } + if ( + typeof descriptor.value !== "string" || + descriptor.value.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentValueLength || + !isEnvironmentValue(descriptor.value) + ) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "environment-value", + }); + } + + const remainingBytes = DECLARATIVE_CONFIG_LIMITS.maxEnvironmentBytes - totalBytes; + const keyBytes = countUtf8BytesUpTo(key, remainingBytes); + if (keyBytes > remainingBytes) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "input", + reason: "environment-bytes", + }); + } + totalBytes += keyBytes; + const remainingValueBytes = DECLARATIVE_CONFIG_LIMITS.maxEnvironmentBytes - totalBytes; + const valueBytes = countUtf8BytesUpTo( + descriptor.value, + remainingValueBytes, + ); + if (valueBytes > remainingValueBytes) { + throw new DeclarativeConfigEvaluationError({ + code: "resource-limit-exceeded", + phase: "input", + reason: "environment-bytes", + }); + } + totalBytes += valueBytes; + defineDataProperty( + output, + key, + descriptor.value, + true, + false, + false, + ); + } + return ObjectFreeze(output); +} + +function sortedEnvironmentKeys( + environment: Readonly>, +): string[] { + const ownKeys = ReflectOwnKeys(environment); + const keys = new IntrinsicArray(); + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + if (typeof key !== "string") { + throw new TypeError("Prepared environment contains a non-string key"); + } + pushValue(keys, key); + } + ReflectApply(ArrayPrototypeSort, keys, [ + (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0, + ]); + return keys; +} + +function addFingerprintFrameLength(total: number, value: string): number { + const frameBytes = 4 + value.length * 2; + if (!NumberIsFinite(frameBytes) || frameBytes > Number.MAX_SAFE_INTEGER - total) { + throw new TypeError("Prepared context fingerprint input is too large"); + } + return total + frameBytes; +} + +function writeUint32(bytes: Uint8Array, offset: number, value: number): number { + bytes[offset] = value >>> 24; + bytes[offset + 1] = value >>> 16; + bytes[offset + 2] = value >>> 8; + bytes[offset + 3] = value; + return offset + 4; +} + +function writeFingerprintFrame( + bytes: Uint8Array, + offset: number, + value: string, +): number { + let cursor = writeUint32(bytes, offset, value.length); + for (let index = 0; index < value.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number; + bytes[cursor] = code >>> 8; + bytes[cursor + 1] = code; + cursor += 2; + } + return cursor; +} + +function canonicalPreparedContextBytes(state: PreparedContextState): Uint8Array { + const keys = sortedEnvironmentKeys(state.tenantEnvironment); + let byteLength = 4; + byteLength = addFingerprintFrameLength(byteLength, FINGERPRINT_NAMESPACE); + byteLength = addFingerprintFrameLength(byteLength, POLICY_VERSION); + byteLength = addFingerprintFrameLength(byteLength, state.environmentName); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + const descriptor = ObjectGetOwnPropertyDescriptor(state.tenantEnvironment, key); + if (!descriptor || !hasOwn(descriptor, "value") || typeof descriptor.value !== "string") { + throw new TypeError("Prepared environment snapshot invariant failed"); + } + byteLength = addFingerprintFrameLength(byteLength, key); + byteLength = addFingerprintFrameLength(byteLength, descriptor.value); + } + + const bytes = new IntrinsicUint8Array(byteLength); + let offset = 0; + offset = writeFingerprintFrame(bytes, offset, FINGERPRINT_NAMESPACE); + offset = writeFingerprintFrame(bytes, offset, POLICY_VERSION); + offset = writeFingerprintFrame(bytes, offset, state.environmentName); + offset = writeUint32(bytes, offset, keys.length); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + const descriptor = ObjectGetOwnPropertyDescriptor(state.tenantEnvironment, key)!; + offset = writeFingerprintFrame(bytes, offset, key); + offset = writeFingerprintFrame(bytes, offset, descriptor.value as string); + } + if (offset !== byteLength) { + throw new TypeError("Prepared context fingerprint framing invariant failed"); + } + return bytes; +} + +async function getFingerprintKey(): Promise { + const subtleCrypto = intrinsicSubtleCrypto; + if (!subtleCrypto) return throwCryptoUnavailable(); + + if (fingerprintKeyPromise === undefined) { + const algorithm = ObjectCreate(null) as HmacKeyGenParams; + defineDataProperty(algorithm, "name", "HMAC", true, false, false); + defineDataProperty(algorithm, "hash", "SHA-256", true, false, false); + defineDataProperty(algorithm, "length", 256, true, false, false); + const usages = new IntrinsicArray(); + pushValue(usages, "sign"); + try { + fingerprintKeyPromise = ReflectApply( + subtleCrypto.generateKey, + subtleCrypto.receiver, + [algorithm, false, usages], + ) as Promise; + } catch { + return throwCryptoUnavailable(); + } + } + + const pending = fingerprintKeyPromise; + try { + return await pending; + } catch { + if (fingerprintKeyPromise === pending) fingerprintKeyPromise = undefined; + return throwCryptoUnavailable(); + } +} + +async function fingerprintPreparedContext( + state: PreparedContextState, +): Promise { + const subtleCrypto = intrinsicSubtleCrypto; + if (!subtleCrypto) return throwCryptoUnavailable(); + + let digest: Uint8Array; + try { + const bytes = canonicalPreparedContextBytes(state); + const signature = await (ReflectApply( + subtleCrypto.sign, + subtleCrypto.receiver, + ["HMAC", await getFingerprintKey(), bytes], + ) as Promise); + digest = new IntrinsicUint8Array(signature); + } catch (error) { + if (error instanceof DeclarativeConfigEvaluationError) throw error; + return throwCryptoUnavailable(); + } + let hexadecimal = ""; + for (let index = 0; index < digest.length; index += 1) { + const value = digest[index]!; + hexadecimal += ReflectApply(StringPrototypeCharAt, HEX_DIGITS, [ + value >>> 4, + ]) as string; + hexadecimal += ReflectApply(StringPrototypeCharAt, HEX_DIGITS, [ + value & 15, + ]) as string; + } + return `${FINGERPRINT_PREFIX}${hexadecimal}`; +} + +function throwCryptoUnavailable(): never { + throw new DeclarativeConfigEvaluationError({ + code: "evaluator-unavailable", + phase: "input", + reason: "crypto-unavailable", + retryable: true, + }); +} + +function setPreparedContextState( + token: object, + state: PreparedContextState, +): void { + ReflectApply(WeakMapPrototypeSet, preparedContextStates, [token, state]); +} + +function getPreparedContextState( + token: unknown, +): PreparedContextState | undefined { + if (typeof token !== "object" || token === null) return undefined; + return ReflectApply( + WeakMapPrototypeGet, + preparedContextStates, + [token], + ) as PreparedContextState | undefined; +} + +function requirePreparedContextState( + token: unknown, +): PreparedContextState { + const state = getPreparedContextState(token); + if (state) return state; + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "prepared-context", + }); +} + +function throwOptionsError( + reason: "options-accessor" | "options-prototype", +): never { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason, + }); +} + +function requireOptionsRecord(value: unknown): object { + if (typeof value !== "object" || value === null) { + return throwOptionsError("options-prototype"); + } + let prototype: object | null; + try { + if (ArrayIsArray(value)) { + return throwOptionsError("options-prototype"); + } + prototype = ObjectGetPrototypeOf(value); + } catch { + return throwOptionsError("options-prototype"); + } + if (prototype !== null && prototype !== ObjectPrototype) { + return throwOptionsError("options-prototype"); + } + return value; +} + +function captureOption( + options: object, + key: "source" | "fileName" | "environmentName" | "environment" | "preparedContext", +): CapturedOption { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ObjectGetOwnPropertyDescriptor(options, key); + } catch { + return throwOptionsError("options-accessor"); + } + if (descriptor === undefined) { + return ObjectFreeze({ present: false, value: undefined }); + } + if (descriptor.enumerable !== true || !hasOwn(descriptor, "value")) { + return throwOptionsError("options-accessor"); + } + return ObjectFreeze({ present: true, value: descriptor.value }); +} + +function captureEvaluationOptions( + value: unknown, +): CapturedEvaluationOptions { + const options = requireOptionsRecord(value); + return ObjectFreeze({ + source: captureOption(options, "source"), + fileName: captureOption(options, "fileName"), + environmentName: captureOption(options, "environmentName"), + environment: captureOption(options, "environment"), + preparedContext: captureOption(options, "preparedContext"), + }); +} + +/** + * Validate and detach environment input once for safe reuse across evaluations. + * + * The returned token is valid only in this JavaScript isolate. Its HMAC + * fingerprint covers the policy version, environment name, and canonical + * environment snapshot, but deliberately does not cover config source. + */ +export async function prepareDeclarativeConfigContext( + options: PrepareDeclarativeConfigContextOptions, +): Promise { + const capturedOptions = captureEvaluationOptions(options); + const state = ObjectCreate(null) as PreparedContextState; + defineDataProperty( + state, + "environmentName", + validateEnvironmentName(capturedOptions.environmentName.value), + true, + false, + false, + ); + defineDataProperty( + state, + "tenantEnvironment", + snapshotTenantEnvironment(capturedOptions.environment.value), + true, + false, + false, + ); + ObjectFreeze(state); + const cacheFingerprint = await fingerprintPreparedContext(state); + const token = ObjectCreate(null) as PreparedDeclarativeConfigContext; + defineDataProperty( + token, + "cacheFingerprint", + cacheFingerprint, + true, + false, + false, + ); + ObjectFreeze(token); + setPreparedContextState(token, state); + return token; +} + +/** + * Create the single DTO a trusted boundary should use for both cache identity + * and worker evaluation. Keeping the values coupled avoids pairing a + * fingerprint from one prepared snapshot with another snapshot's environment. + * + * @internal + */ +export function createPreparedDeclarativeConfigWorkerPayload( + source: string, + preparedContext: PreparedDeclarativeConfigContext, + fileName: DeclarativeConfigFileName = DECLARATIVE_CONFIG_FILE_NAME, +): PreparedDeclarativeConfigWorkerPayload { + if (!isDeclarativeConfigFileName(fileName)) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "config-file-name", + }); + } + if (typeof source !== "string") { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "source-bytes", + }); + } + if (countUtf8BytesBounded(source) > DECLARATIVE_CONFIG_LIMITS.maxSourceBytes) { + throw new DeclarativeConfigEvaluationError({ + code: "source-too-large", + phase: "input", + reason: "source-bytes", + }); + } + + const state = requirePreparedContextState(preparedContext); + const fingerprintDescriptor = ObjectGetOwnPropertyDescriptor( + preparedContext, + "cacheFingerprint", + ); + if ( + !fingerprintDescriptor || + !hasOwn(fingerprintDescriptor, "value") || + typeof fingerprintDescriptor.value !== "string" + ) { + throw new TypeError("Prepared context fingerprint invariant failed"); + } + + const evaluationOptions = ObjectCreate( + null, + ) as PreparedDeclarativeConfigWorkerEvaluationOptions; + defineDataProperty( + evaluationOptions, + "source", + source, + true, + false, + false, + ); + defineDataProperty( + evaluationOptions, + "fileName", + fileName, + true, + false, + false, + ); + defineDataProperty( + evaluationOptions, + "environmentName", + state.environmentName, + true, + false, + false, + ); + defineDataProperty( + evaluationOptions, + "environment", + state.tenantEnvironment, + true, + false, + false, + ); + ObjectFreeze(evaluationOptions); + + const payload = ObjectCreate(null) as PreparedDeclarativeConfigWorkerPayload; + defineDataProperty( + payload, + "cacheFingerprint", + fingerprintDescriptor.value, + true, + false, + false, + ); + defineDataProperty( + payload, + "policyVersion", + POLICY_VERSION, + true, + false, + false, + ); + defineDataProperty( + payload, + "evaluationOptions", + evaluationOptions, + true, + false, + false, + ); + return ObjectFreeze(payload); +} + +function createEnvironment(parent: LexicalEnvironment | null = null): LexicalEnvironment { + return { + bindings: ObjectCreate(null) as Record, + parent, + }; +} + +function lookupBinding( + environment: LexicalEnvironment, + name: string, +): Binding | undefined { + let current: LexicalEnvironment | null = environment; + while (current !== null) { + const descriptor = ObjectGetOwnPropertyDescriptor(current.bindings, name); + if (descriptor && hasOwn(descriptor, "value")) { + return descriptor.value as Binding; + } + current = current.parent; + } + return undefined; +} + +function isForbiddenGlobal(name: string): boolean { + switch (name) { + case "Bun": + case "Deno": + case "Function": + case "Infinity": + case "NaN": + case "Object": + case "Proxy": + case "Reflect": + case "WebAssembly": + case "WebSocket": + case "Worker": + case "document": + case "eval": + case "exports": + case "fetch": + case "global": + case "globalThis": + case "module": + case "process": + case "require": + case "self": + case "undefined": + case "window": + return true; + default: + return false; + } +} + +function declareBinding( + context: EvaluationContext, + environment: LexicalEnvironment, + name: string, + binding: Binding, + node: ASTNode, + count = true, +): void { + if (isForbiddenGlobal(name)) { + return throwEvaluationError( + "forbidden-capability", + "validate", + "host-global", + context, + node, + ); + } + if (hasOwn(environment.bindings, name)) { + return throwEvaluationError( + "invalid-binding", + "validate", + "duplicate-binding", + context, + node, + ); + } + if (count) { + context.bindingCount += 1; + if (context.bindingCount > DECLARATIVE_CONFIG_LIMITS.maxBindings) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "binding-count", + context, + node, + ); + } + } + defineDataProperty( + environment.bindings, + name, + binding, + true, + false, + false, + ); +} + +function addEvaluationStep( + context: EvaluationContext, + node: ASTNode, + count = 1, +): void { + if (count > DECLARATIVE_CONFIG_LIMITS.maxEvaluationSteps - context.evaluationSteps) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "evaluation-steps", + context, + node, + ); + } + context.evaluationSteps += count; +} + +function addSpreadOperation(context: EvaluationContext, node: ASTNode): void { + context.spreadOperations += 1; + if (context.spreadOperations > DECLARATIVE_CONFIG_LIMITS.maxSpreadOperations) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "spread-operations", + context, + node, + ); + } +} + +function addSpreadCopy(context: EvaluationContext, node: ASTNode): void { + context.spreadCopies += 1; + if (context.spreadCopies > DECLARATIVE_CONFIG_LIMITS.maxSpreadCopies) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "spread-copies", + context, + node, + ); + } + addEvaluationStep(context, node); +} + +function chargeString( + context: EvaluationContext, + length: number, + node: ASTNode, +): void { + if ( + length > + DECLARATIVE_CONFIG_LIMITS.maxIntermediateStringUnits - + context.intermediateStringUnits + ) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "intermediate-string", + context, + node, + ); + } + context.intermediateStringUnits += length; +} + +function assertValidationDepth( + context: EvaluationContext, + node: ASTNode, + depth: number, +): void { + if (depth > DECLARATIVE_CONFIG_LIMITS.maxValidationDepth) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "evaluation-depth", + context, + node, + ); + } +} + +function assertEvaluationDepth( + context: EvaluationContext, + node: ASTNode, + depth: number, +): void { + if (depth > DECLARATIVE_CONFIG_LIMITS.maxEvaluationDepth) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "evaluation-depth", + context, + node, + ); + } +} + +function identifierName( + node: ASTNode, + context: EvaluationContext, +): string { + if (node.type !== "Identifier" || typeof node.name !== "string") { + return throwEvaluationError( + "parser-contract-violation", + "validate", + "ast-shape", + context, + node, + ); + } + return node.name; +} + +function helperFromImportedName(name: string): HelperName | null { + switch (name) { + case "defineConfig": + case "defineConfigWithEnv": + case "getEnv": + case "mergeConfigs": + return name; + default: + return null; + } +} + +function processImport( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + countBindings = true, +): void { + if ( + !isAstNode(node.source) || + node.source.type !== "StringLiteral" || + node.source.value !== "veryfront" || + !ArrayIsArray(node.specifiers) || + (ArrayIsArray(node.attributes) && node.attributes.length !== 0) || + (ArrayIsArray(node.assertions) && node.assertions.length !== 0) + ) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-import", + context, + node, + ); + } + if (node.specifiers.length === 0) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "import-form", + context, + node, + ); + } + if (node.specifiers.length > DECLARATIVE_CONFIG_LIMITS.maxImportSpecifiers) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "arguments", + context, + node, + ); + } + + const declarationTypeOnly = node.importKind === "type"; + for (let index = 0; index < node.specifiers.length; index += 1) { + const specifier = requireAstNode(node.specifiers[index], context, node); + if (specifier.type !== "ImportSpecifier") { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "import-form", + context, + specifier, + ); + } + const typeOnly = declarationTypeOnly || specifier.importKind === "type"; + if (typeOnly) continue; + + const imported = requireAstNode(specifier.imported, context, specifier); + const local = requireAstNode(specifier.local, context, specifier); + const importedName = identifierName(imported, context); + const localName = identifierName(local, context); + const helper = helperFromImportedName(importedName); + if (helper === null) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-import", + context, + imported, + ); + } + declareBinding( + context, + environment, + localName, + ObjectFreeze({ kind: "helper", helper }), + local, + countBindings, + ); + } +} + +function isTypeOnlyDeclaration(node: ASTNode): boolean { + return node.type === "TSInterfaceDeclaration" || + node.type === "TSTypeAliasDeclaration"; +} + +function expressionArray( + node: ASTNode, + field: "arguments" | "elements" | "expressions" | "properties", + context: EvaluationContext, +): unknown[] { + return requireNodeArray(node[field], context, node); +} + +function validateIdentifierExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, +): void { + const name = identifierName(node, context); + const binding = lookupBinding(environment, name); + if (!binding) { + return throwEvaluationError( + isForbiddenGlobal(name) ? "forbidden-capability" : "invalid-binding", + "validate", + isForbiddenGlobal(name) ? "host-global" : "unbound-identifier", + context, + node, + ); + } + if (binding.kind === "helper") { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-as-value", + context, + node, + ); + } +} + +function validateLiteral(node: ASTNode, context: EvaluationContext): void { + switch (node.type) { + case "StringLiteral": + if (typeof node.value === "string") return; + break; + case "NumericLiteral": + if (typeof node.value === "number") { + if (!NumberIsFinite(node.value)) { + return throwEvaluationError( + "non-finite-number", + "validate", + "non-finite-result", + context, + node, + ); + } + return; + } + break; + case "BooleanLiteral": + if (typeof node.value === "boolean") return; + break; + case "NullLiteral": + return; + } + return throwEvaluationError( + "parser-contract-violation", + "validate", + "ast-shape", + context, + node, + ); +} + +function staticObjectKey( + property: ASTNode, + context: EvaluationContext, +): string { + const keyNode = requireAstNode(property.key, context, property); + let key: string; + if (keyNode.type === "Identifier") { + key = identifierName(keyNode, context); + } else if (keyNode.type === "StringLiteral" && typeof keyNode.value === "string") { + key = keyNode.value; + } else if (keyNode.type === "NumericLiteral" && typeof keyNode.value === "number") { + if (!NumberIsFinite(keyNode.value)) { + return throwEvaluationError( + "non-finite-number", + "validate", + "non-finite-result", + context, + keyNode, + ); + } + key = `${keyNode.value}`; + } else { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "object-key", + context, + keyNode, + ); + } + + if ( + key.length > DECLARATIVE_CONFIG_LIMITS.maxObjectKeyLength || + key === "__proto__" || + key === "constructor" || + key === "prototype" + ) { + return throwEvaluationError( + key === "__proto__" || key === "constructor" || key === "prototype" + ? "forbidden-capability" + : "resource-limit-exceeded", + "validate", + key === "__proto__" || key === "constructor" || key === "prototype" + ? "dangerous-key" + : "object-key", + context, + keyNode, + ); + } + return key; +} + +function validateObjectExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + const properties = expressionArray(node, "properties", context); + if (properties.length > DECLARATIVE_CONFIG_LIMITS.maxObjectProperties) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "object-properties", + context, + node, + ); + } + + const explicitKeys = ObjectCreate(null) as Record; + for (let index = 0; index < properties.length; index += 1) { + const property = requireAstNode(properties[index], context, node); + if (property.type === "SpreadElement") { + addSpreadOperation(context, property); + validateExpression( + requireAstNode(property.argument, context, property), + context, + environment, + depth + 1, + ); + continue; + } + if ( + property.type === "ObjectMethod" || + property.type === "ArrowFunctionExpression" || + property.type === "FunctionExpression" + ) { + return throwEvaluationError( + "unsupported-hosted-feature", + "validate", + "function-value", + context, + property, + ); + } + if ( + property.type !== "ObjectProperty" || + property.computed === true || + property.method === true + ) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-expression", + context, + property, + ); + } + const key = staticObjectKey(property, context); + if (hasOwn(explicitKeys, key)) { + return throwEvaluationError( + "invalid-result", + "validate", + "duplicate-key", + context, + property, + ); + } + defineDataProperty(explicitKeys, key, true, false, false, false); + validateExpression( + requireAstNode(property.value, context, property), + context, + environment, + depth + 1, + ); + } +} + +function validateArrayExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + const elements = expressionArray(node, "elements", context); + if (elements.length > DECLARATIVE_CONFIG_LIMITS.maxArrayElements) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "array-elements", + context, + node, + ); + } + for (let index = 0; index < elements.length; index += 1) { + const element = elements[index]; + if (element === null) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "array-elements", + context, + node, + ); + } + const elementNode = requireAstNode(element, context, node); + if (elementNode.type === "SpreadElement") { + addSpreadOperation(context, elementNode); + validateExpression( + requireAstNode(elementNode.argument, context, elementNode), + context, + environment, + depth + 1, + ); + } else { + validateExpression(elementNode, context, environment, depth + 1); + } + } +} + +function validateTemplateLiteral( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + const expressions = expressionArray(node, "expressions", context); + const quasis = requireNodeArray(node.quasis, context, node); + if ( + expressions.length > DECLARATIVE_CONFIG_LIMITS.maxTemplateExpressions || + quasis.length !== expressions.length + 1 + ) { + return throwEvaluationError( + expressions.length > DECLARATIVE_CONFIG_LIMITS.maxTemplateExpressions + ? "resource-limit-exceeded" + : "parser-contract-violation", + "validate", + expressions.length > DECLARATIVE_CONFIG_LIMITS.maxTemplateExpressions + ? "template-expressions" + : "ast-shape", + context, + node, + ); + } + for (let index = 0; index < quasis.length; index += 1) { + const quasi = requireAstNode(quasis[index], context, node); + const value = quasi.value; + if ( + quasi.type !== "TemplateElement" || + typeof value !== "object" || + value === null || + typeof (value as Record).cooked !== "string" + ) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-expression", + context, + quasi, + ); + } + if (index < expressions.length) { + validateExpression( + requireAstNode(expressions[index], context, node), + context, + environment, + depth + 1, + ); + } + } +} + +function validateEnvironmentFactory( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + assertValidationDepth(context, node, depth); + if ( + node.type === "TSAsExpression" || + node.type === "TSSatisfiesExpression" || + node.type === "TSNonNullExpression" || + node.type === "TSTypeAssertion" + ) { + return validateEnvironmentFactory( + requireAstNode(node.expression, context, node), + context, + environment, + depth + 1, + ); + } + if ( + node.type !== "ArrowFunctionExpression" || + node.async === true || + node.generator === true || + !ArrayIsArray(node.params) || + node.params.length !== 1 || + !isAstNode(node.params[0]) || + node.params[0].type !== "Identifier" || + !isAstNode(node.body) || + node.body.type === "BlockStatement" + ) { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + node, + ); + } + + const child = createEnvironment(environment); + const parameter = node.params[0]; + declareBinding( + context, + child, + identifierName(parameter, context), + ObjectFreeze({ kind: "value", value: context.environmentName }), + parameter, + ); + validateExpression(node.body, context, child, depth + 1); +} + +function resolveCalledHelper( + call: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, +): HelperName { + const callee = requireAstNode(call.callee, context, call); + if (callee.type !== "Identifier") { + return throwEvaluationError( + "forbidden-capability", + "validate", + "unsupported-call", + context, + callee, + ); + } + const name = identifierName(callee, context); + const binding = lookupBinding(environment, name); + if (!binding) { + return throwEvaluationError( + isForbiddenGlobal(name) ? "forbidden-capability" : "invalid-binding", + "validate", + isForbiddenGlobal(name) ? "host-global" : "unbound-identifier", + context, + callee, + ); + } + if (binding.kind !== "helper") { + return throwEvaluationError( + "forbidden-capability", + "validate", + "unsupported-call", + context, + callee, + ); + } + return binding.helper; +} + +function validateCallExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + if (node.optional === true) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-call", + context, + node, + ); + } + const args = expressionArray(node, "arguments", context); + if (args.length > DECLARATIVE_CONFIG_LIMITS.maxArguments) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "arguments", + context, + node, + ); + } + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (isAstNode(argument) && argument.type === "SpreadElement") { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + argument, + ); + } + } + + const helper = resolveCalledHelper(node, context, environment); + switch (helper) { + case "defineConfig": + if (args.length !== 1) { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + node, + ); + } + validateExpression( + requireAstNode(args[0], context, node), + context, + environment, + depth + 1, + ); + return; + case "getEnv": { + if (args.length !== 1) { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + node, + ); + } + const argument = requireAstNode(args[0], context, node); + if ( + argument.type !== "StringLiteral" || + typeof argument.value !== "string" || + argument.value.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentKeyLength || + !isEnvironmentKey(argument.value) + ) { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + argument, + ); + } + return; + } + case "mergeConfigs": + for (let index = 0; index < args.length; index += 1) { + validateExpression( + requireAstNode(args[index], context, node), + context, + environment, + depth + 1, + ); + } + return; + case "defineConfigWithEnv": + if (args.length !== 1) { + return throwEvaluationError( + "invalid-helper-usage", + "validate", + "helper-arguments", + context, + node, + ); + } + validateEnvironmentFactory( + requireAstNode(args[0], context, node), + context, + environment, + depth + 1, + ); + } +} + +function validateExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): void { + assertValidationDepth(context, node, depth); + switch (node.type) { + case "StringLiteral": + case "NumericLiteral": + case "BooleanLiteral": + case "NullLiteral": + return validateLiteral(node, context); + case "Identifier": + return validateIdentifierExpression(node, context, environment); + case "ObjectExpression": + return validateObjectExpression(node, context, environment, depth); + case "ArrayExpression": + return validateArrayExpression(node, context, environment, depth); + case "TemplateLiteral": + return validateTemplateLiteral(node, context, environment, depth); + case "CallExpression": + return validateCallExpression(node, context, environment, depth); + case "LogicalExpression": { + if (node.operator !== "&&" && node.operator !== "||" && node.operator !== "??") { + break; + } + validateExpression( + requireAstNode(node.left, context, node), + context, + environment, + depth + 1, + ); + validateExpression( + requireAstNode(node.right, context, node), + context, + environment, + depth + 1, + ); + return; + } + case "BinaryExpression": { + const allowed = node.operator === "===" || + node.operator === "!==" || + node.operator === "<" || + node.operator === "<=" || + node.operator === ">" || + node.operator === ">=" || + node.operator === "+" || + node.operator === "-" || + node.operator === "*" || + node.operator === "/" || + node.operator === "%"; + if (!allowed) break; + validateExpression( + requireAstNode(node.left, context, node), + context, + environment, + depth + 1, + ); + validateExpression( + requireAstNode(node.right, context, node), + context, + environment, + depth + 1, + ); + return; + } + case "UnaryExpression": + if (node.operator !== "!" && node.operator !== "+" && node.operator !== "-") break; + validateExpression( + requireAstNode(node.argument, context, node), + context, + environment, + depth + 1, + ); + return; + case "ConditionalExpression": + validateExpression( + requireAstNode(node.test, context, node), + context, + environment, + depth + 1, + ); + validateExpression( + requireAstNode(node.consequent, context, node), + context, + environment, + depth + 1, + ); + validateExpression( + requireAstNode(node.alternate, context, node), + context, + environment, + depth + 1, + ); + return; + case "TSAsExpression": + case "TSSatisfiesExpression": + case "TSNonNullExpression": + case "TSTypeAssertion": + return validateExpression( + requireAstNode(node.expression, context, node), + context, + environment, + depth + 1, + ); + case "ArrowFunctionExpression": + case "FunctionDeclaration": + case "FunctionExpression": + case "ObjectMethod": + return throwEvaluationError( + "unsupported-hosted-feature", + "validate", + "function-value", + context, + node, + ); + } + + return throwEvaluationError( + node.type === "NewExpression" || + node.type === "MemberExpression" || + node.type === "OptionalCallExpression" || + node.type === "OptionalMemberExpression" + ? "forbidden-capability" + : "unsupported-syntax", + "validate", + node.type === "NewExpression" || + node.type === "MemberExpression" || + node.type === "OptionalCallExpression" || + node.type === "OptionalMemberExpression" + ? "unsupported-call" + : "unsupported-expression", + context, + node, + ); +} + +function isRuntimeRecord(value: RuntimeValue): value is RuntimeRecord { + return typeof value === "object" && + value !== null && + !ArrayIsArray(value) && + ObjectGetPrototypeOf(value) === null; +} + +function runtimeRecordKeys( + value: RuntimeRecord, + context: EvaluationContext, + node: ASTNode, +): string[] { + const keys = ReflectOwnKeys(value); + const output = new IntrinsicArray(); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + if (typeof key !== "string") { + return throwEvaluationError( + "invalid-result", + "evaluate", + "result-not-snapshot-safe", + context, + node, + ); + } + pushValue(output, key); + } + return output; +} + +function runtimeRecordValue( + value: RuntimeRecord, + key: string, +): RuntimeValue { + const descriptor = ObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !hasOwn(descriptor, "value")) { + throw new TypeError("Runtime record invariant failed"); + } + return descriptor.value as RuntimeValue; +} + +/** + * Primordial-hardened parity with isBoundedCorsOrigin in + * utils/cors-policy-limits.ts. Calling the shared helper directly would + * reintroduce mutable String/Array prototype dispatch at this trust boundary. + */ +function isHostedCorsOrigin(value: string): boolean { + if ( + value.length === 0 || + value.length > MAX_CORS_ORIGIN_LENGTH || + (ReflectApply(StringPrototypeTrim, value, []) as string) !== value + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number; + if (code <= 0x1f || code === 0x7f || code > 0xff) return false; + } + return true; +} + +function isHostedCorsOriginList(value: readonly RuntimeValue[]): boolean { + if (value.length === 0 || value.length > MAX_CORS_ORIGIN_COUNT) return false; + let serializedLength = (value.length - 1) * 2; + for (let index = 0; index < value.length; index += 1) { + const origin = value[index]; + if (typeof origin !== "string" || !isHostedCorsOrigin(origin)) return false; + serializedLength += origin.length; + if (serializedLength > MAX_CORS_ORIGIN_LIST_LENGTH) return false; + } + return true; +} + +function isHostedExtensionDisableDirective(value: RuntimeValue): boolean { + if (!isRuntimeRecord(value)) return false; + const keys = ReflectOwnKeys(value); + if ( + keys.length !== 2 || + !hasOwn(value, "name") || + !hasOwn(value, "enabled") + ) { + return false; + } + const name = runtimeRecordValue(value, "name"); + const enabled = runtimeRecordValue(value, "enabled"); + return typeof name === "string" && + isHostedExtensionName(name) && + enabled === false; +} + +function isHostedExtensionName(name: string): boolean { + if ( + name.length === 0 || + name.length > MAX_HOSTED_EXTENSION_NAME_LENGTH || + (ReflectApply(StringPrototypeTrim, name, []) as string) !== name + ) { + return false; + } + for (let index = 0; index < name.length; index += 1) { + const code = ReflectApply(StringPrototypeCharCodeAt, name, [index]) as number; + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +function isHostedMemoryRenderCacheKey(key: PropertyKey): key is string { + return key === "type" || + key === "ttl" || + key === "maxEntries" || + key === "public"; +} + +function isHostedCacheKey(key: PropertyKey): key is string { + return key === "bundleManifest" || + key === "render" || + key === "queryParams"; +} + +function isHostedBundleManifestKey(key: PropertyKey): key is string { + return key === "enabled" || + key === "type" || + key === "ttl"; +} + +function enforceHostedCachePolicy( + result: RuntimeRecord, + context: EvaluationContext, + program: ASTNode, +): void { + if (!hasOwn(result, "cache")) return; + const cache = runtimeRecordValue(result, "cache"); + if (!isRuntimeRecord(cache)) return; + + // Keep the cache family itself fail closed. A future cache subsystem cannot + // become tenant-selectable merely because the trusted schema learns it. + const cacheKeys = ReflectOwnKeys(cache); + for (let index = 0; index < cacheKeys.length; index += 1) { + const key = cacheKeys[index]!; + if (key === "dir") { + // cache.dir is shared by the filesystem render backend and other on-disk + // cache consumers. + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-cache-directory", + context, + program, + ); + } + if (!isHostedCacheKey(key)) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-cache-option", + context, + program, + ); + } + } + + if (hasOwn(cache, "bundleManifest")) { + const bundleManifest = runtimeRecordValue(cache, "bundleManifest"); + if (isRuntimeRecord(bundleManifest)) { + const keys = ReflectOwnKeys(bundleManifest); + for (let index = 0; index < keys.length; index += 1) { + if (!isHostedBundleManifestKey(keys[index]!)) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-bundle-manifest-backend", + context, + program, + ); + } + } + if ( + hasOwn(bundleManifest, "type") && + runtimeRecordValue(bundleManifest, "type") !== "memory" + ) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-bundle-manifest-backend", + context, + program, + ); + } + } + } + + if (!hasOwn(cache, "render")) return; + const render = runtimeRecordValue(cache, "render"); + if (!isRuntimeRecord(render)) return; + + // Keep this allowlist fail closed. A newly added backend-specific option + // cannot become tenant-selectable until this hosted boundary reviews it. + const keys = ReflectOwnKeys(render); + for (let index = 0; index < keys.length; index += 1) { + if (!isHostedMemoryRenderCacheKey(keys[index]!)) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-render-cache-backend", + context, + program, + ); + } + } + + if ( + hasOwn(render, "type") && + runtimeRecordValue(render, "type") !== "memory" + ) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-render-cache-backend", + context, + program, + ); + } + + if (hasOwn(render, "maxEntries")) { + const maxEntries = runtimeRecordValue(render, "maxEntries"); + if ( + typeof maxEntries === "number" && + maxEntries > MAX_HOSTED_RENDER_CACHE_ENTRIES + ) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-render-cache-capacity", + context, + program, + ); + } + } +} + +function enforceHostedResultPolicy( + result: RuntimeRecord, + context: EvaluationContext, + program: ASTNode, +): void { + enforceHostedCachePolicy(result, context, program); + + if (hasOwn(result, "extensions")) { + const extensions = runtimeRecordValue(result, "extensions"); + if (!ArrayIsArray(extensions)) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-extensions", + context, + program, + ); + } + for (let index = 0; index < extensions.length; index += 1) { + if (!isHostedExtensionDisableDirective(extensions[index])) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-extensions", + context, + program, + ); + } + } + } + + if (hasOwn(result, "middleware")) { + const middleware = runtimeRecordValue(result, "middleware"); + if (isRuntimeRecord(middleware) && hasOwn(middleware, "custom")) { + const custom = runtimeRecordValue(middleware, "custom"); + if (!ArrayIsArray(custom) || custom.length !== 0) { + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-custom-middleware", + context, + program, + ); + } + } + } + + if (!hasOwn(result, "security")) return; + const security = runtimeRecordValue(result, "security"); + if (!isRuntimeRecord(security) || !hasOwn(security, "cors")) return; + const cors = runtimeRecordValue(security, "cors"); + if (!isRuntimeRecord(cors) || !hasOwn(cors, "origin")) return; + const origin = runtimeRecordValue(cors, "origin"); + if ( + (typeof origin === "string" && isHostedCorsOrigin(origin)) || + (ArrayIsArray(origin) && isHostedCorsOriginList(origin)) + ) { + return; + } + return throwEvaluationError( + "unsupported-hosted-feature", + "result", + "hosted-cors-origin", + context, + program, + ); +} + +function defineRuntimeProperty( + target: Record, + key: string, + value: RuntimeValue, + context: EvaluationContext, + node: ASTNode, + currentCount: number, +): number { + const exists = hasOwn(target, key); + if (!exists && currentCount >= DECLARATIVE_CONFIG_LIMITS.maxObjectProperties) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "object-properties", + context, + node, + ); + } + defineDataProperty(target, key, value, true, false, true); + return exists ? currentCount : currentCount + 1; +} + +function copyRuntimeRecord( + target: Record, + source: RuntimeRecord, + context: EvaluationContext, + node: ASTNode, + currentCount: number, +): number { + const keys = runtimeRecordKeys(source, context, node); + let count = currentCount; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + const descriptor = ObjectGetOwnPropertyDescriptor(source, key); + if (!descriptor || !hasOwn(descriptor, "value")) { + return throwEvaluationError( + "invalid-result", + "evaluate", + "result-not-snapshot-safe", + context, + node, + ); + } + addSpreadCopy(context, node); + count = defineRuntimeProperty( + target, + key, + descriptor.value as RuntimeValue, + context, + node, + count, + ); + } + return count; +} + +function evaluateObjectExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeRecord { + const properties = node.properties as unknown[]; + const output = ObjectCreate(null) as Record; + let keyCount = 0; + for (let index = 0; index < properties.length; index += 1) { + const property = properties[index] as ASTNode; + if (property.type === "SpreadElement") { + const spread = evaluateExpression( + property.argument as ASTNode, + context, + environment, + depth + 1, + ); + if (!isRuntimeRecord(spread)) { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + property, + ); + } + keyCount = copyRuntimeRecord(output, spread, context, property, keyCount); + continue; + } + + const key = staticObjectKey(property, context); + const value = evaluateExpression( + property.value as ASTNode, + context, + environment, + depth + 1, + ); + addEvaluationStep(context, property); + keyCount = defineRuntimeProperty( + output, + key, + value, + context, + property, + keyCount, + ); + } + return ObjectFreeze(output); +} + +function appendArrayValue( + output: RuntimeValue[], + value: RuntimeValue, + context: EvaluationContext, + node: ASTNode, +): void { + if (output.length >= DECLARATIVE_CONFIG_LIMITS.maxArrayElements) { + return throwEvaluationError( + "resource-limit-exceeded", + "evaluate", + "array-elements", + context, + node, + ); + } + pushValue(output, value); +} + +function evaluateArrayExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): readonly RuntimeValue[] { + const elements = node.elements as unknown[]; + const output = new IntrinsicArray(); + for (let index = 0; index < elements.length; index += 1) { + const element = elements[index] as ASTNode; + if (element.type === "SpreadElement") { + const spread = evaluateExpression( + element.argument as ASTNode, + context, + environment, + depth + 1, + ); + if (!ArrayIsArray(spread)) { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + element, + ); + } + for (let spreadIndex = 0; spreadIndex < spread.length; spreadIndex += 1) { + addSpreadCopy(context, element); + appendArrayValue(output, spread[spreadIndex], context, element); + } + } else { + appendArrayValue( + output, + evaluateExpression(element, context, environment, depth + 1), + context, + element, + ); + } + } + return ObjectFreeze(output); +} + +function primitiveTemplateString( + value: RuntimeValue, + context: EvaluationContext, + node: ASTNode, +): string { + switch (typeof value) { + case "string": + return value; + case "boolean": + return value ? "true" : "false"; + case "number": + return `${value}`; + case "undefined": + return throwEvaluationError( + "invalid-result", + "evaluate", + "result-not-snapshot-safe", + context, + node, + ); + case "object": + if (value === null) return "null"; + break; + } + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + node, + ); +} + +function appendBoundedString( + current: string, + addition: string, + context: EvaluationContext, + node: ASTNode, +): string { + if (addition.length > CONFIG_SNAPSHOT_LIMITS.maxStringLength - current.length) { + return throwEvaluationError( + "invalid-result", + "evaluate", + "result-not-snapshot-safe", + context, + node, + ); + } + const next = `${current}${addition}`; + chargeString(context, next.length, node); + return next; +} + +function evaluateTemplateLiteral( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): string { + const expressions = node.expressions as ASTNode[]; + const quasis = node.quasis as ASTNode[]; + let output = ""; + for (let index = 0; index < quasis.length; index += 1) { + const cooked = (quasis[index]!.value as Record).cooked as string; + output = appendBoundedString(output, cooked, context, quasis[index]!); + if (index < expressions.length) { + const value = evaluateExpression( + expressions[index]!, + context, + environment, + depth + 1, + ); + output = appendBoundedString( + output, + primitiveTemplateString(value, context, expressions[index]!), + context, + expressions[index]!, + ); + } + } + return output; +} + +function runtimeTruthy(value: RuntimeValue): boolean { + if (value === null || value === undefined || value === false) return false; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.length !== 0; + return true; +} + +function requireNumber( + value: RuntimeValue, + context: EvaluationContext, + node: ASTNode, +): number { + if (typeof value !== "number") { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + node, + ); + } + return value; +} + +function finiteResult( + value: number, + context: EvaluationContext, + node: ASTNode, +): number { + if (!NumberIsFinite(value)) { + return throwEvaluationError( + "non-finite-number", + "evaluate", + "non-finite-result", + context, + node, + ); + } + return value; +} + +function isComparisonPrimitive( + value: RuntimeValue, +): value is RuntimePrimitive { + return value === null || + value === undefined || + typeof value === "boolean" || + typeof value === "number" || + typeof value === "string"; +} + +function evaluateBinaryExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeValue { + const left = evaluateExpression( + node.left as ASTNode, + context, + environment, + depth + 1, + ); + const right = evaluateExpression( + node.right as ASTNode, + context, + environment, + depth + 1, + ); + const operator = node.operator; + + if (operator === "===" || operator === "!==") { + if (!isComparisonPrimitive(left) || !isComparisonPrimitive(right)) { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + node, + ); + } + const equal = left === right; + return operator === "===" ? equal : !equal; + } + if ( + operator === "<" || + operator === "<=" || + operator === ">" || + operator === ">=" + ) { + if ( + (typeof left !== "number" || typeof right !== "number") && + (typeof left !== "string" || typeof right !== "string") + ) { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + node, + ); + } + if (operator === "<") return left < right; + if (operator === "<=") return left <= right; + if (operator === ">") return left > right; + return left >= right; + } + + if (operator === "+") { + if (typeof left === "string" && typeof right === "string") { + return appendBoundedString(left, right, context, node); + } + if (typeof left === "string" || typeof right === "string") { + return throwEvaluationError( + "evaluation-type-error", + "evaluate", + "operand-type", + context, + node, + ); + } + } + + const leftNumber = requireNumber(left, context, node); + const rightNumber = requireNumber(right, context, node); + switch (operator) { + case "+": + return finiteResult(leftNumber + rightNumber, context, node); + case "-": + return finiteResult(leftNumber - rightNumber, context, node); + case "*": + return finiteResult(leftNumber * rightNumber, context, node); + case "/": + return finiteResult(leftNumber / rightNumber, context, node); + case "%": + return finiteResult(leftNumber % rightNumber, context, node); + } + return throwEvaluationError( + "unsupported-syntax", + "evaluate", + "unsupported-expression", + context, + node, + ); +} + +function evaluateLogicalExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeValue { + const left = evaluateExpression( + node.left as ASTNode, + context, + environment, + depth + 1, + ); + if (node.operator === "&&") { + return runtimeTruthy(left) + ? evaluateExpression(node.right as ASTNode, context, environment, depth + 1) + : left; + } + if (node.operator === "||") { + return runtimeTruthy(left) + ? left + : evaluateExpression(node.right as ASTNode, context, environment, depth + 1); + } + return left !== null && left !== undefined + ? left + : evaluateExpression(node.right as ASTNode, context, environment, depth + 1); +} + +function evaluateEnvironmentFactory( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeRecord { + if ( + node.type === "TSAsExpression" || + node.type === "TSSatisfiesExpression" || + node.type === "TSNonNullExpression" || + node.type === "TSTypeAssertion" + ) { + return evaluateEnvironmentFactory( + node.expression as ASTNode, + context, + environment, + depth + 1, + ); + } + + const parameter = (node.params as ASTNode[])[0]!; + const child = createEnvironment(environment); + declareBinding( + context, + child, + parameter.name as string, + ObjectFreeze({ kind: "value", value: context.environmentName }), + parameter, + false, + ); + const value = evaluateExpression( + node.body as ASTNode, + context, + child, + depth + 1, + ); + if (!isRuntimeRecord(value)) { + return throwEvaluationError( + "invalid-helper-usage", + "evaluate", + "helper-arguments", + context, + node, + ); + } + return value; +} + +function evaluateCallExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeValue { + const helper = resolveCalledHelper(node, context, environment); + const args = node.arguments as ASTNode[]; + if (helper === "defineConfigWithEnv") { + return evaluateEnvironmentFactory(args[0]!, context, environment, depth + 1); + } + + const values = new IntrinsicArray(); + for (let index = 0; index < args.length; index += 1) { + pushValue( + values, + evaluateExpression(args[index]!, context, environment, depth + 1), + ); + } + + switch (helper) { + case "defineConfig": { + const value = values[0]; + if (!isRuntimeRecord(value)) { + return throwEvaluationError( + "invalid-helper-usage", + "evaluate", + "helper-arguments", + context, + node, + ); + } + return value; + } + case "getEnv": { + const key = values[0]; + if ( + typeof key !== "string" || + key.length > DECLARATIVE_CONFIG_LIMITS.maxEnvironmentKeyLength || + !isEnvironmentKey(key) + ) { + return throwEvaluationError( + "invalid-helper-usage", + "evaluate", + "helper-arguments", + context, + node, + ); + } + const descriptor = ObjectGetOwnPropertyDescriptor(context.tenantEnvironment, key); + if (!descriptor || !hasOwn(descriptor, "value")) return undefined; + chargeString(context, (descriptor.value as string).length, node); + return descriptor.value as string; + } + case "mergeConfigs": { + const output = ObjectCreate(null) as Record; + let keyCount = 0; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!isRuntimeRecord(value)) { + return throwEvaluationError( + "invalid-helper-usage", + "evaluate", + "helper-arguments", + context, + node, + ); + } + keyCount = copyRuntimeRecord(output, value, context, node, keyCount); + } + return ObjectFreeze(output); + } + } + return throwEvaluationError( + "invalid-helper-usage", + "evaluate", + "helper-arguments", + context, + node, + ); +} + +function evaluateExpression( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, + depth: number, +): RuntimeValue { + assertEvaluationDepth(context, node, depth); + addEvaluationStep(context, node); + switch (node.type) { + case "StringLiteral": + chargeString(context, (node.value as string).length, node); + return node.value as string; + case "NumericLiteral": + case "BooleanLiteral": + return node.value as number | boolean; + case "NullLiteral": + return null; + case "Identifier": { + const binding = lookupBinding(environment, node.name as string); + if (!binding || binding.kind !== "value") { + return throwEvaluationError( + "invalid-binding", + "evaluate", + "unbound-identifier", + context, + node, + ); + } + return binding.value; + } + case "ObjectExpression": + return evaluateObjectExpression(node, context, environment, depth); + case "ArrayExpression": + return evaluateArrayExpression(node, context, environment, depth); + case "TemplateLiteral": + return evaluateTemplateLiteral(node, context, environment, depth); + case "CallExpression": + return evaluateCallExpression(node, context, environment, depth); + case "LogicalExpression": + return evaluateLogicalExpression(node, context, environment, depth); + case "BinaryExpression": + return evaluateBinaryExpression(node, context, environment, depth); + case "UnaryExpression": { + const value = evaluateExpression( + node.argument as ASTNode, + context, + environment, + depth + 1, + ); + if (node.operator === "!") return !runtimeTruthy(value); + const numeric = requireNumber(value, context, node); + return node.operator === "+" + ? finiteResult(numeric, context, node) + : finiteResult(-numeric, context, node); + } + case "ConditionalExpression": + return runtimeTruthy( + evaluateExpression( + node.test as ASTNode, + context, + environment, + depth + 1, + ), + ) + ? evaluateExpression( + node.consequent as ASTNode, + context, + environment, + depth + 1, + ) + : evaluateExpression( + node.alternate as ASTNode, + context, + environment, + depth + 1, + ); + case "TSAsExpression": + case "TSSatisfiesExpression": + case "TSNonNullExpression": + case "TSTypeAssertion": + return evaluateExpression( + node.expression as ASTNode, + context, + environment, + depth + 1, + ); + } + return throwEvaluationError( + "unsupported-syntax", + "evaluate", + "unsupported-expression", + context, + node, + ); +} + +function validateVariableDeclaration( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, +): void { + if ( + node.kind !== "const" || + node.declare === true || + !ArrayIsArray(node.declarations) + ) { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-statement", + context, + node, + ); + } + for (let index = 0; index < node.declarations.length; index += 1) { + const declarator = requireAstNode(node.declarations[index], context, node); + const id = requireAstNode(declarator.id, context, declarator); + const init = requireAstNode(declarator.init, context, declarator); + if (declarator.type !== "VariableDeclarator" || id.type !== "Identifier") { + return throwEvaluationError( + "invalid-binding", + "validate", + "unbound-identifier", + context, + declarator, + ); + } + validateExpression(init, context, environment, 0); + declareBinding( + context, + environment, + identifierName(id, context), + ObjectFreeze({ kind: "value", value: undefined }), + id, + ); + } +} + +function evaluateVariableDeclaration( + node: ASTNode, + context: EvaluationContext, + environment: LexicalEnvironment, +): void { + const declarations = node.declarations as ASTNode[]; + for (let index = 0; index < declarations.length; index += 1) { + const declarator = declarations[index]!; + const id = declarator.id as ASTNode; + const init = declarator.init as ASTNode; + const value = evaluateExpression(init, context, environment, 0); + declareBinding( + context, + environment, + identifierName(id, context), + ObjectFreeze({ kind: "value", value }), + id, + false, + ); + } +} + +function processProgram( + program: ASTNode, + context: EvaluationContext, +): RuntimeValue { + const body = program.body as unknown[]; + if (body.length > DECLARATIVE_CONFIG_LIMITS.maxTopLevelStatements) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "statement-count", + context, + program, + ); + } + + const validationEnvironment = createEnvironment(); + let importCount = 0; + let defaultCount = 0; + + for (let index = 0; index < body.length; index += 1) { + const statement = requireAstNode(body[index], context, program); + if (statement.type === "ImportDeclaration") { + importCount += 1; + if (importCount > DECLARATIVE_CONFIG_LIMITS.maxImports) { + return throwEvaluationError( + "resource-limit-exceeded", + "validate", + "unsupported-import", + context, + statement, + ); + } + processImport(statement, context, validationEnvironment); + } + if (statement.type === "ExportDefaultDeclaration") defaultCount += 1; + if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") { + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-export", + context, + statement, + ); + } + } + + if (defaultCount === 0) { + return throwEvaluationError( + "invalid-result", + "validate", + "missing-default-export", + context, + program, + ); + } + if (defaultCount > 1) { + return throwEvaluationError( + "invalid-result", + "validate", + "duplicate-default-export", + context, + program, + ); + } + + for (let index = 0; index < body.length; index += 1) { + const statement = body[index] as ASTNode; + if (statement.type === "ImportDeclaration" || isTypeOnlyDeclaration(statement)) { + continue; + } + if (statement.type === "VariableDeclaration") { + validateVariableDeclaration(statement, context, validationEnvironment); + continue; + } + if (statement.type === "ExportDefaultDeclaration") { + validateExpression( + requireAstNode(statement.declaration, context, statement), + context, + validationEnvironment, + 0, + ); + continue; + } + return throwEvaluationError( + "unsupported-syntax", + "validate", + "unsupported-statement", + context, + statement, + ); + } + + const evaluationEnvironment = createEnvironment(); + for (let index = 0; index < body.length; index += 1) { + const statement = body[index] as ASTNode; + if (statement.type === "ImportDeclaration") { + processImport(statement, context, evaluationEnvironment, false); + } + } + + let result: RuntimeValue; + let hasResult = false; + for (let index = 0; index < body.length; index += 1) { + const statement = body[index] as ASTNode; + if (statement.type === "ImportDeclaration" || isTypeOnlyDeclaration(statement)) { + continue; + } + if (statement.type === "VariableDeclaration") { + evaluateVariableDeclaration(statement, context, evaluationEnvironment); + continue; + } + if (statement.type === "ExportDefaultDeclaration") { + const declaration = requireAstNode(statement.declaration, context, statement); + result = evaluateExpression(declaration, context, evaluationEnvironment, 0); + hasResult = true; + continue; + } + } + + if (!hasResult) { + return throwEvaluationError( + "invalid-result", + "validate", + "missing-default-export", + context, + program, + ); + } + return result!; +} + +function resolveEvaluationState( + options: CapturedEvaluationOptions, +): PreparedContextState { + if (options.preparedContext.present) { + if (options.environment.present || options.environmentName.present) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "prepared-context", + }); + } + const state = getPreparedContextState(options.preparedContext.value); + if (!state) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "prepared-context", + }); + } + return state; + } + + const state = ObjectCreate(null) as PreparedContextState; + defineDataProperty( + state, + "environmentName", + validateEnvironmentName(options.environmentName.value), + true, + false, + false, + ); + defineDataProperty( + state, + "tenantEnvironment", + snapshotTenantEnvironment(options.environment.value), + true, + false, + false, + ); + return ObjectFreeze(state); +} + +interface CapturedEvaluationInput { + readonly source: string; + readonly fileName: DeclarativeConfigFileName; + readonly preparedState: PreparedContextState; +} + +function captureEvaluationInput( + options: DeclarativeConfigEvaluationOptions, +): CapturedEvaluationInput { + const capturedOptions = captureEvaluationOptions(options); + if (typeof capturedOptions.source.value !== "string") { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "source-bytes", + }); + } + const source = capturedOptions.source.value; + if (countUtf8BytesBounded(source) > DECLARATIVE_CONFIG_LIMITS.maxSourceBytes) { + throw new DeclarativeConfigEvaluationError({ + code: "source-too-large", + phase: "input", + reason: "source-bytes", + }); + } + const fileName = capturedOptions.fileName.present + ? capturedOptions.fileName.value + : DECLARATIVE_CONFIG_FILE_NAME; + if (!isDeclarativeConfigFileName(fileName)) { + throw new DeclarativeConfigEvaluationError({ + code: "input-invalid", + phase: "input", + reason: "config-file-name", + }); + } + const preparedState = resolveEvaluationState(capturedOptions); + return ObjectFreeze({ source, fileName, preparedState }); +} + +async function evaluateCapturedInput( + input: CapturedEvaluationInput, + parser: TrustedCodeParser, +): Promise { + const { source, fileName, preparedState } = input; + let parsedAst: unknown; + try { + parsedAst = await parser.parse({ + code: source, + filePath: fileName, + }); + } catch (error) { + const reason = parserErrorReason(error); + throw new DeclarativeConfigEvaluationError({ + code: reason === "duplicate-binding" + ? "invalid-binding" + : reason === "duplicate-default-export" + ? "invalid-result" + : "syntax-error", + phase: reason === "duplicate-binding" || + reason === "duplicate-default-export" + ? "validate" + : "parse", + reason, + location: parseErrorLocation(source, error, fileName), + }); + } + + if (!isAstNode(parsedAst)) { + throw new DeclarativeConfigEvaluationError({ + code: "parser-contract-violation", + phase: "validate", + reason: "ast-shape", + }); + } + const ast = parsedAst; + preflightAst(ast, source, fileName); + const program = extractProgram(ast, source, fileName); + const context: EvaluationContext = { + source, + fileName, + tenantEnvironment: preparedState.tenantEnvironment, + environmentName: preparedState.environmentName, + bindingCount: 0, + evaluationSteps: 0, + spreadOperations: 0, + spreadCopies: 0, + intermediateStringUnits: 0, + }; + const result = processProgram(program, context); + if (!isRuntimeRecord(result)) { + return throwEvaluationError( + "invalid-result", + "result", + "result-not-record", + context, + program, + ); + } + enforceHostedResultPolicy(result, context, program); + + try { + const snapshot: ConfigSnapshotValue = canonicalizeConfigSnapshot(result); + if ( + typeof snapshot !== "object" || + snapshot === null || + ArrayIsArray(snapshot) + ) { + return throwEvaluationError( + "invalid-result", + "result", + "result-not-record", + context, + program, + ); + } + return snapshot as ConfigSnapshotRecord; + } catch (error) { + if (error instanceof DeclarativeConfigEvaluationError) throw error; + if (error instanceof ConfigSnapshotError) { + return throwEvaluationError( + "invalid-result", + "result", + error.code === "dangerous-key" ? "dangerous-key" : "result-not-snapshot-safe", + context, + program, + ); + } + throw error; + } +} + +/** + * Evaluate with a parser statically loaded in the initial module graph of a + * worker created with no permissions. + * + * @internal The worker must statically import the trusted parser while being + * created with `permissions: "none"`; this entry never falls back to dynamic + * loading. Structured clone does not preserve frozen descriptors or null + * prototypes, so a receiver must recanonicalize and deeply freeze the worker + * result before exposing it as a trusted snapshot. + */ +export async function evaluateDeclarativeConfigWithParser( + options: DeclarativeConfigEvaluationOptions, + parser: unknown, +): Promise { + const input = captureEvaluationInput(options); + return await evaluateCapturedInput(input, captureTrustedParser(parser)); +} + +/** + * Parse and evaluate hosted configuration source without executing it. + * + * The returned root is a detached, deeply frozen, null-prototype record. + * Environment lookups never fall through to host process state. + */ +export async function evaluateDeclarativeConfig( + options: DeclarativeConfigEvaluationOptions, +): Promise { + const input = captureEvaluationInput(options); + return await evaluateCapturedInput(input, await getTrustedParser()); +} diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 462aed6d36..1beadfc515 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -7,8 +7,6 @@ import { DEFAULT_METRICS_COLLECT_INTERVAL_MS, DEFAULT_PORT, DEFAULT_PREFETCH_DELAY_MS, - DEFAULT_REDIS_BATCH_DELETE_SIZE, - DEFAULT_REDIS_SCAN_COUNT, DEFAULT_TIMEOUT_MS, defaultConfig, DURATION_HISTOGRAM_BOUNDARIES_MS, @@ -67,14 +65,6 @@ describe("config/defaults", () => { assertEquals(DEFAULT_METRICS_COLLECT_INTERVAL_MS, 60000); }); - it("should have correct DEFAULT_REDIS_SCAN_COUNT", () => { - assertEquals(DEFAULT_REDIS_SCAN_COUNT, 100); - }); - - it("should have correct DEFAULT_REDIS_BATCH_DELETE_SIZE", () => { - assertEquals(DEFAULT_REDIS_BATCH_DELETE_SIZE, 1000); - }); - it("should have correct PAGE_TRANSITION_DELAY_MS", () => { assertEquals(PAGE_TRANSITION_DELAY_MS, 150); }); @@ -119,6 +109,17 @@ describe("config/defaults", () => { }); describe("defaultConfig", () => { + it("keeps exported defaults immutable at runtime", () => { + assertEquals(Object.isFrozen(DURATION_HISTOGRAM_BOUNDARIES_MS), true); + assertEquals(Object.isFrozen(SIZE_HISTOGRAM_BOUNDARIES_KB), true); + assertEquals(Object.isFrozen(defaultConfig), true); + assertEquals(Object.isFrozen(defaultConfig.server), true); + assertEquals(Object.isFrozen(defaultConfig.timeouts), true); + assertEquals(Object.isFrozen(defaultConfig.cache), true); + assertEquals(Object.isFrozen(defaultConfig.cache.jit), true); + assertEquals(Object.isFrozen(defaultConfig.metrics), true); + }); + it("should have server config with correct port and hostname", () => { assertEquals(defaultConfig.server.port, DEFAULT_PORT); assertEquals(defaultConfig.server.hostname, "0.0.0.0"); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index e90f7153dd..a3b8b89745 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -10,66 +10,77 @@ export const SANDBOX_TIMEOUT_MS = 5000; export const DATA_FETCH_TIMEOUT_MS = 10000; export const DEFAULT_CACHE_MAX_SIZE = 100; +/** Default production render-cache capacity. */ +export const DEFAULT_RENDER_CACHE_MAX_ENTRIES = 500; +/** + * Shared hosted projects may lower, but never raise, the production + * render-cache capacity. + */ +export const MAX_HOSTED_RENDER_CACHE_ENTRIES = DEFAULT_RENDER_CACHE_MAX_ENTRIES; -export const DURATION_HISTOGRAM_BOUNDARIES_MS = [ - 5, - 10, - 25, - 50, - 75, - 100, - 250, - 500, - 750, - 1000, - 2500, - 5000, - 7500, - 10000, -] as const; +export const DURATION_HISTOGRAM_BOUNDARIES_MS = Object.freeze( + [ + 5, + 10, + 25, + 50, + 75, + 100, + 250, + 500, + 750, + 1000, + 2500, + 5000, + 7500, + 10000, + ] as const, +); -export const SIZE_HISTOGRAM_BOUNDARIES_KB = [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - 10000, -] as const; +export const SIZE_HISTOGRAM_BOUNDARIES_KB = Object.freeze( + [ + 1, + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1000, + 2500, + 5000, + 10000, + ] as const, +); -export const defaultConfig = { - server: { - port: DEFAULT_PORT, - hostname: "0.0.0.0", - }, - timeouts: { - default: DEFAULT_TIMEOUT_MS, - api: 30000, - ssr: SSR_TIMEOUT_MS, - hmr: 30000, - sandbox: SANDBOX_TIMEOUT_MS, - }, - cache: { - jit: { - maxSize: DEFAULT_CACHE_MAX_SIZE, - tempDirPrefix: "vf-bundle-", - }, - }, - metrics: { - ssrBoundaries: DURATION_HISTOGRAM_BOUNDARIES_MS, - }, -} as const; +export const defaultConfig = Object.freeze( + { + server: Object.freeze({ + port: DEFAULT_PORT, + hostname: "0.0.0.0", + }), + timeouts: Object.freeze({ + default: DEFAULT_TIMEOUT_MS, + api: 30000, + ssr: SSR_TIMEOUT_MS, + hmr: 30000, + sandbox: SANDBOX_TIMEOUT_MS, + }), + cache: Object.freeze({ + jit: Object.freeze({ + maxSize: DEFAULT_CACHE_MAX_SIZE, + tempDirPrefix: "vf-bundle-", + }), + }), + metrics: Object.freeze({ + ssrBoundaries: DURATION_HISTOGRAM_BOUNDARIES_MS, + }), + } as const, +); export const DEFAULT_PREFETCH_DELAY_MS = 100; export const DEFAULT_METRICS_COLLECT_INTERVAL_MS = 60000; -export const DEFAULT_REDIS_SCAN_COUNT = 100; -export const DEFAULT_REDIS_BATCH_DELETE_SIZE = 1000; export const PAGE_TRANSITION_DELAY_MS = 150; export type DefaultConfig = typeof defaultConfig; diff --git a/src/config/define-config-core.ts b/src/config/define-config-core.ts new file mode 100644 index 0000000000..8e6bcfd856 --- /dev/null +++ b/src/config/define-config-core.ts @@ -0,0 +1,23 @@ +import type { VeryfrontConfig, VeryfrontConfigInput } from "./schemas/index.ts"; + +/** Define a Veryfront project configuration object. */ +export function defineConfig(config: T): T { + return config; +} + +/** Apply a configuration factory to an already resolved environment name. */ +export function defineConfigForEnvironment( + factory: (env: string) => T, + environmentName: string, +): T { + return factory(environmentName); +} + +/** Merge multiple partial Veryfront configuration objects into one config object. */ +export function mergeConfigs(...configs: Partial[]): VeryfrontConfig; +export function mergeConfigs(...configs: Partial[]): VeryfrontConfigInput; +export function mergeConfigs( + ...configs: Partial[] +): VeryfrontConfigInput { + return Object.assign({}, ...configs); +} diff --git a/src/config/define-config.client.ts b/src/config/define-config.client.ts new file mode 100644 index 0000000000..622849e5f1 --- /dev/null +++ b/src/config/define-config.client.ts @@ -0,0 +1,23 @@ +import { getEnv } from "#veryfront/platform/compat/process/env.ts"; +import type { VeryfrontConfigInput } from "./schemas/index.ts"; +import { defineConfigForEnvironment } from "./define-config-core.ts"; + +export { defineConfig, mergeConfigs } from "./define-config-core.ts"; + +type ClientEnvironmentConfig = { + nodeEnv: string; +}; + +function readClientEnvironmentConfig(): ClientEnvironmentConfig { + return { + nodeEnv: getEnv("NODE_ENV") ?? getEnv("DENO_ENV") ?? "development", + }; +} + +/** Define a Veryfront project configuration from the current environment name. */ +export function defineConfigWithEnv( + factory: (env: string) => T, + envConfig: ClientEnvironmentConfig = readClientEnvironmentConfig(), +): T { + return defineConfigForEnvironment(factory, envConfig.nodeEnv); +} diff --git a/src/config/define-config.test.ts b/src/config/define-config.test.ts index 777cedabf8..8147d616b1 100644 --- a/src/config/define-config.test.ts +++ b/src/config/define-config.test.ts @@ -1,12 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { expect } from "#std/expect.ts"; +import { defineConfig, defineConfigWithEnv, mergeConfigs } from "./define-config.ts"; import { - defineConfig, - defineConfigWithEnv, - mergeConfigs, - validateConfig, -} from "./define-config.ts"; + defineConfig as clientDefineConfig, + defineConfigWithEnv as clientDefineConfigWithEnv, + mergeConfigs as clientMergeConfigs, +} from "./define-config.client.ts"; import { defineConfig as publicDefineConfig, defineConfigWithEnv as publicDefineConfigWithEnv, @@ -75,6 +75,15 @@ describe("define-config", () => { }; expect(invalidConnector.integrations).toBeDefined(); }); + + it("rejects malformed extension entries at the public authoring boundary", () => { + const invalidConfig: VeryfrontConfigInput = { + // @ts-expect-error extension entries must be materialized extensions or disable directives + extensions: ["not-an-extension"], + }; + + expect(invalidConfig.extensions).toEqual(["not-an-extension"]); + }); }); describe("public root exports", () => { @@ -108,6 +117,21 @@ describe("define-config", () => { }); }); + describe("client-safe exports", () => { + it("shares pure helpers and preserves the environment factory contract", () => { + const compatibleHelper: typeof defineConfigWithEnv = clientDefineConfigWithEnv; + + expect(clientDefineConfig).toBe(defineConfig); + expect(clientMergeConfigs).toBe(mergeConfigs); + expect( + compatibleHelper( + (nodeEnv) => clientMergeConfigs({ title: "Client" }, { description: nodeEnv }), + { nodeEnv: "production" }, + ), + ).toEqual({ title: "Client", description: "production" }); + }); + }); + describe("defineConfigWithEnv", () => { it("should use development as default environment", () => { const testEnv = createTestEnvironmentConfig({ nodeEnv: "development" }); @@ -121,6 +145,14 @@ describe("define-config", () => { expect(result.title).toBe("App-production"); }); + it("accepts the minimal environment contract it reads", () => { + const result = defineConfigWithEnv( + (env) => ({ title: `App-${env}` }), + { nodeEnv: "production" }, + ); + expect(result.title).toBe("App-production"); + }); + it("should allow environment-specific configuration", () => { const testEnv = createTestEnvironmentConfig({ nodeEnv: "production" }); const result = defineConfigWithEnv( @@ -224,85 +256,4 @@ describe("define-config", () => { expect(result.title).toBe("Third"); }); }); - - describe("validateConfig", () => { - it("should accept valid config", async () => { - const config: VeryfrontConfig = { title: "Valid App", dev: { port: 3008 } }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should reject null config", async () => { - await expect(validateConfig(null)).rejects.toThrow("Configuration must be an object"); - }); - - it("should reject undefined config", async () => { - await expect(validateConfig(undefined)).rejects.toThrow("Configuration must be an object"); - }); - - it("should reject non-object config", async () => { - const message = "Configuration must be an object"; - await expect(validateConfig("string")).rejects.toThrow(message); - await expect(validateConfig(123)).rejects.toThrow(message); - await expect(validateConfig(true)).rejects.toThrow(message); - }); - - it("should accept config without dev.port", async () => { - const config: VeryfrontConfig = { title: "App without port" }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should reject invalid dev.port (too low)", async () => { - await expect(validateConfig({ dev: { port: 0 } })).rejects.toThrow( - "dev.port must be a number between", - ); - }); - - it("should reject invalid dev.port (too high)", async () => { - await expect(validateConfig({ dev: { port: 99999 } })).rejects.toThrow( - "dev.port must be a number between", - ); - }); - - it("should reject non-number dev.port", async () => { - await expect(validateConfig({ dev: { port: "not a number" } })).rejects.toThrow( - "dev.port must be a number between", - ); - }); - - it("should accept valid port within range", async () => { - const config: VeryfrontConfig = { dev: { port: 3009 } }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should reject non-string build.outDir", async () => { - await expect(validateConfig({ build: { outDir: 123 } })).rejects.toThrow( - "build.outDir must be a string", - ); - }); - - it("should accept valid build.outDir", async () => { - const config: VeryfrontConfig = { build: { outDir: "custom-dist" } }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should accept config without build section", async () => { - const config: VeryfrontConfig = { title: "No build config" }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should accept empty config object", async () => { - const config: VeryfrontConfig = {}; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - - it("should accept config with multiple valid sections", async () => { - const config: VeryfrontConfig = { - title: "Complete App", - description: "Full config", - dev: { port: 3010, open: true }, - build: { outDir: "build" }, - }; - await expect(validateConfig(config)).resolves.toBeUndefined(); - }); - }); }); diff --git a/src/config/define-config.ts b/src/config/define-config.ts index df4c0c841f..9a9c6838ff 100644 --- a/src/config/define-config.ts +++ b/src/config/define-config.ts @@ -1,75 +1,13 @@ -import type { VeryfrontConfig, VeryfrontConfigInput } from "./schemas/index.ts"; -import { createError, toError } from "#veryfront/errors/veryfront-error.ts"; +import type { VeryfrontConfigInput } from "./schemas/index.ts"; import { type EnvironmentConfig, getEnvironmentConfig } from "./environment-config.ts"; +import { defineConfigForEnvironment } from "./define-config-core.ts"; -/** Define a Veryfront project configuration object. */ -export function defineConfig(config: T): T { - return config; -} +export { defineConfig, mergeConfigs } from "./define-config-core.ts"; /** Define a Veryfront project configuration from the current environment name. */ export function defineConfigWithEnv( factory: (env: string) => T, - envConfig: EnvironmentConfig = getEnvironmentConfig(), + envConfig: Pick = getEnvironmentConfig(), ): T { - return factory(envConfig.nodeEnv); -} - -/** Merge multiple partial Veryfront configuration objects into one config object. */ -export function mergeConfigs(...configs: Partial[]): VeryfrontConfig; -export function mergeConfigs(...configs: Partial[]): VeryfrontConfigInput; -export function mergeConfigs( - ...configs: Partial[] -): VeryfrontConfigInput { - return Object.assign({}, ...configs); -} - -export async function validateConfig(config: unknown): Promise { - if (!config || typeof config !== "object") { - throw toError( - createError({ type: "config", message: "Configuration must be an object" }), - ); - } - - const cfg = config as Record; - - await validatePort(cfg); - validateOutDir(cfg); -} - -async function validatePort(cfg: Record): Promise { - const dev = cfg.dev; - const port = dev && typeof dev === "object" ? (dev as Record).port : undefined; - if (port === undefined) return; - - const { MIN_PORT, MAX_PORT } = await import("../utils/constants/index.ts"); - if (typeof port === "number" && port >= MIN_PORT && port <= MAX_PORT) return; - - throw toError( - createError({ - type: "config", - message: `dev.port must be a number between ${MIN_PORT} and ${MAX_PORT}`, - context: { - field: "dev.port", - value: port, - expected: `number between ${MIN_PORT} and ${MAX_PORT}`, - }, - }), - ); -} - -function validateOutDir(cfg: Record): void { - const build = cfg.build; - const outDir = build && typeof build === "object" - ? (build as Record).outDir - : undefined; - if (outDir === undefined || typeof outDir === "string") return; - - throw toError( - createError({ - type: "config", - message: "build.outDir must be a string", - context: { field: "build.outDir", value: outDir, expected: "string" }, - }), - ); + return defineConfigForEnvironment(factory, envConfig.nodeEnv); } diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 39d19bba3b..7c5be6f22c 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; -import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; import type { EnvironmentConfig } from "./environment-config.ts"; import { @@ -13,11 +13,11 @@ import { getForceColorEnv, getGithubEnvConfig, getGoogleGenAIEnvConfig, + getMistralEnvConfig, getNoColorEnv, getOpenAIEnvConfig, getOtelMetricsConfig, getOtelTracingConfig, - getRedisUrlEnv, getSsrMaxConcurrentTransformsEnv, getV8FlagsEnv, getVeryfrontVersion, @@ -58,8 +58,10 @@ const BASE_MOCK_ENV: EnvironmentConfig = { disableLruInterval: false, appUrl: undefined, port: 3000, + portSource: "environment", requestTimeoutMs: undefined, httpFetchTimeoutMs: undefined, + extensionSetupTimeoutMs: undefined, ssrMaxConcurrentTransforms: 3, otelEnabled: false, otelServiceName: undefined, @@ -118,23 +120,10 @@ describe("config/env", () => { ); }); - it("should return default when env value is 0", () => { + it("should preserve 0 so callers can disable the concurrency limit", () => { assertEquals( getSsrMaxConcurrentTransformsEnv(50, createMockEnv({ ssrMaxConcurrentTransforms: 0 })), - 50, - ); - }); - }); - - describe("getRedisUrlEnv", () => { - it("should return undefined by default", () => { - assertEquals(getRedisUrlEnv(createMockEnv()), undefined); - }); - - it("should return url when set", () => { - assertEquals( - getRedisUrlEnv(createMockEnv({ redisUrl: "redis://localhost:6379" })), - "redis://localhost:6379", + 0, ); }); }); @@ -209,13 +198,15 @@ describe("config/env", () => { describe("getOpenAIEnvConfig", () => { const keys = ["OPENAI_API_KEY", "OPENAI_BASE_URL"]; - afterEach(() => { + const clearKeys = () => { for (const k of keys) { try { deleteEnv(k); } catch { /* ignore */ } } - }); + }; + beforeEach(clearKeys); + afterEach(clearKeys); it("should return empty config by default", () => { const config = getOpenAIEnvConfig(); @@ -274,6 +265,31 @@ describe("config/env", () => { }); }); + describe("getMistralEnvConfig", () => { + const keys = ["MISTRAL_API_KEY", "MISTRAL_BASE_URL"]; + afterEach(() => { + for (const k of keys) { + try { + deleteEnv(k); + } catch { /* ignore */ } + } + }); + + it("uses the public Mistral endpoint by default", () => { + const config = getMistralEnvConfig(); + assertEquals(config.apiKey, undefined); + assertEquals(config.baseURL, "https://api.mistral.ai/v1"); + }); + + it("returns configured credentials and endpoint", () => { + setEnv("MISTRAL_API_KEY", "mistral-test"); + setEnv("MISTRAL_BASE_URL", "https://mistral.example/v1"); + const config = getMistralEnvConfig(); + assertEquals(config.apiKey, "mistral-test"); + assertEquals(config.baseURL, "https://mistral.example/v1"); + }); + }); + describe("isDebugEnvEnabled", () => { it("should return false by default", () => { assertEquals(isDebugEnvEnabled(createMockEnv()), false); diff --git a/src/config/env.ts b/src/config/env.ts index 394dd2677f..4ec4c67ff5 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -44,13 +44,7 @@ export function getSsrMaxConcurrentTransformsEnv( defaultValue = 3, env: EnvironmentConfig = getEnvironmentConfig(), ): number { - return env.ssrMaxConcurrentTransforms || defaultValue; -} - -export function getRedisUrlEnv( - env: EnvironmentConfig = getEnvironmentConfig(), -): string | undefined { - return env.redisUrl; + return env.ssrMaxConcurrentTransforms ?? defaultValue; } export function getV8FlagsEnv(env: EnvironmentConfig = getEnvironmentConfig()): string { diff --git a/src/config/environment-config.test.ts b/src/config/environment-config.test.ts index e0db7d04b8..3a613047ae 100644 --- a/src/config/environment-config.test.ts +++ b/src/config/environment-config.test.ts @@ -4,18 +4,23 @@ import "#veryfront/schemas/_test-setup.ts"; * @module */ -import { afterEach, beforeEach, describe, it } from "#std/testing/bdd.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { expect } from "#std/expect.ts"; import { _resetEnvironmentConfig, _setEnvironmentConfigForTesting, createTestEnvironmentConfig, + DEFAULT_REQUEST_TIMEOUT_MS, type EnvironmentConfig, getEnvironmentConfig, initEnvironmentConfig, isEnvironmentConfigInitialized, + refreshEnvironmentConfig, } from "./environment-config.ts"; import { __resetEnvLoaderForTests, markEnvLoaded } from "#veryfront/utils/env-loader.ts"; +import { withEnv } from "#veryfront/testing/deno-compat.ts"; +import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; +import { logger } from "#veryfront/utils/logger/logger.ts"; describe("EnvironmentConfig", () => { beforeEach(_resetEnvironmentConfig); @@ -65,6 +70,44 @@ describe("EnvironmentConfig", () => { expect(retrieved).toBe(initialized); }); + + it("keeps early snapshots immutable and uncached until environment loading completes", async () => { + __resetEnvLoaderForTests(); + const originalWarn = logger.warn; + const warnings: string[] = []; + logger.warn = (message: string) => warnings.push(message); + + try { + await withEnv({ VERYFRONT_API_TOKEN: "before-load" }, async () => { + const first = getEnvironmentConfig(); + expect(first.apiToken).toBe("before-load"); + expect(Object.isFrozen(first)).toBe(true); + expect(isEnvironmentConfigInitialized()).toBe(false); + + setEnv("VERYFRONT_API_TOKEN", "after-mutation"); + const second = getEnvironmentConfig(); + expect(second.apiToken).toBe("after-mutation"); + expect(second).not.toBe(first); + expect(Object.isFrozen(second)).toBe(true); + expect(isEnvironmentConfigInitialized()).toBe(false); + expect(warnings).toHaveLength(1); + + const earlyInit = initEnvironmentConfig(); + expect(earlyInit.apiToken).toBe("after-mutation"); + expect(Object.isFrozen(earlyInit)).toBe(true); + expect(isEnvironmentConfigInitialized()).toBe(false); + + markEnvLoaded(); + const loaded = getEnvironmentConfig(); + expect(loaded.apiToken).toBe("after-mutation"); + expect(Object.isFrozen(loaded)).toBe(true); + expect(isEnvironmentConfigInitialized()).toBe(true); + expect(getEnvironmentConfig()).toBe(loaded); + }); + } finally { + logger.warn = originalWarn; + } + }); }); describe("isEnvironmentConfigInitialized", () => { @@ -89,6 +132,7 @@ describe("EnvironmentConfig", () => { const env = createTestEnvironmentConfig(); expect(env.nodeEnv).toBe("test"); + expect(env.veryfrontEnv).toBe("test"); expect(env.debug).toBe(false); expect(env.ci).toBe(false); expect(env.denoTesting).toBe(false); @@ -123,6 +167,37 @@ describe("EnvironmentConfig", () => { expect(env.nodeEnv).toBe("production"); }); + + it("allows explicit test environment names without inheriting host values", async () => { + await withEnv( + { + NODE_ENV: "production", + VERYFRONT_ENV: "production", + VERYFRONT_API_TOKEN: "host-api-token", + OPENAI_API_KEY: "host-openai-key", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://host-collector.example", + HOME: "/host/home", + }, + async () => { + _resetEnvironmentConfig(); + + const defaultEnv = createTestEnvironmentConfig(); + const overriddenEnv = createTestEnvironmentConfig({ + nodeEnv: "development", + veryfrontEnv: "preview", + }); + + expect(defaultEnv.nodeEnv).toBe("test"); + expect(defaultEnv.veryfrontEnv).toBe("test"); + expect(overriddenEnv.nodeEnv).toBe("development"); + expect(overriddenEnv.veryfrontEnv).toBe("preview"); + expect(defaultEnv.apiToken).toBeUndefined(); + expect(defaultEnv.openaiApiKey).toBeUndefined(); + expect(defaultEnv.otelEndpoint).toBeUndefined(); + expect(defaultEnv.homeDir).toBeUndefined(); + }, + ); + }); }); describe("_setEnvironmentConfigForTesting", () => { @@ -205,69 +280,249 @@ describe("EnvironmentConfig", () => { expect(env.ssrMaxConcurrentTransforms).toBe(3); }); - it("handles all EnvironmentConfig properties", () => { - const env = createTestEnvironmentConfig(); + it("maps every environment source to the exact config field", async () => { + await withEnv( + { + NODE_ENV: "production", + VERYFRONT_ENV: "preview", + VERYFRONT_MODE: "hosted", + PROXY_MODE: "1", + VERYFRONT_DEBUG: "yes", + CI: "true", + DENO_TESTING: "1", + VERYFRONT_PERF: "1", + VERYFRONT_API_BASE_URL: "https://api-base.example", + VERYFRONT_PUBLIC_API_BASE_URL: "https://public-api.example", + VERYFRONT_API_URL: "https://graphql.example/graphql", + VERYFRONT_API_TOKEN: "vf-token", + VERYFRONT_PROJECT_SLUG: "project-slug", + HOME: "/home/veryfront", + XDG_CONFIG_HOME: "/config/veryfront", + CONTINUOUS_INTEGRATION: "yes", + SSH_CLIENT: "192.0.2.1 1234 22", + SSH_TTY: "/dev/pts/1", + DISPLAY: ":1", + WAYLAND_DISPLAY: "wayland-1", + CURSOR_SESSION: "cursor-session", + VERYFRONT_SERVER_START_TIME: "2026-07-25T10:00:00Z", + VCR: "record", + VERYFRONT_EXPERIMENTAL_RSC: "1", + REDIS_URL: "redis://cache.example:6379", + VERYFRONT_CACHE_DIR: "/cache/veryfront", + VF_DISABLE_LRU_INTERVAL: "1", + APP_URL: "https://app.example", + PORT: "4321", + REQUEST_TIMEOUT_MS: "70000", + VF_HTTP_FETCH_TIMEOUT: "25000", + VF_EXTENSION_SETUP_TIMEOUT_MS: "15000", + SSR_MAX_CONCURRENT_TRANSFORMS: "7", + VERYFRONT_OTEL: "true", + OTEL_SERVICE_NAME: "veryfront-test", + OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://otel.example/traces", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "https://otel.example/metrics", + OTEL_TRACES_EXPORTER: "otlp", + OTEL_METRICS_EXPORTER: "otlp", + OTEL_EXPORTER_OTLP_HEADERS: "authorization=test", + OTEL_METRICS_ENABLED: "yes", + OPENAI_API_KEY: "openai-key", + OPENAI_BASE_URL: "https://openai.example", + ANTHROPIC_API_KEY: "anthropic-key", + ANTHROPIC_BASE_URL: "https://anthropic.example", + GOOGLE_API_KEY: "google-key", + GITHUB_TOKEN: "github-token", + GITHUB_OWNER: "veryfront", + GITHUB_REPO: "veryfront-code", + GITHUB_REF: "refs/heads/main", + NO_COLOR: "", + FORCE_COLOR: "2", + DENO_V8_FLAGS: "--trace-gc", + V8_MAX_OLD_SPACE_SIZE: "4096", + VERYFRONT_VERSION: "1.2.3", + }, + async () => { + _resetEnvironmentConfig(); + const env = refreshEnvironmentConfig(); + + expect(env).toEqual( + { + nodeEnv: "production", + veryfrontEnv: "preview", + veryfrontMode: "hosted", + proxyMode: true, + debug: true, + ci: true, + denoTesting: true, + perfEnabled: true, + apiBaseUrl: "https://api-base.example", + publicApiBaseUrl: "https://public-api.example", + apiUrl: "https://graphql.example/graphql", + apiToken: "vf-token", + projectSlug: "project-slug", + homeDir: "/home/veryfront", + xdgConfigHome: "/config/veryfront", + continuousIntegration: true, + sshClient: "192.0.2.1 1234 22", + sshTty: "/dev/pts/1", + display: ":1", + waylandDisplay: "wayland-1", + cursorSession: "cursor-session", + serverStartTime: "2026-07-25T10:00:00Z", + vcr: "record", + experimentalRsc: true, + redisUrl: "redis://cache.example:6379", + cacheDir: "/cache/veryfront", + disableLruInterval: true, + appUrl: "https://app.example", + port: 4321, + portSource: "environment", + requestTimeoutMs: 70000, + httpFetchTimeoutMs: 25000, + extensionSetupTimeoutMs: 15000, + ssrMaxConcurrentTransforms: 7, + otelEnabled: true, + otelServiceName: "veryfront-test", + otelEndpoint: "https://otel.example", + otelTracesEndpoint: "https://otel.example/traces", + otelMetricsEndpoint: "https://otel.example/metrics", + otelTracesExporter: "otlp", + otelMetricsExporter: "otlp", + otelHeaders: "authorization=test", + otelMetricsEnabled: true, + openaiApiKey: "openai-key", + openaiBaseUrl: "https://openai.example", + anthropicApiKey: "anthropic-key", + anthropicBaseUrl: "https://anthropic.example", + googleApiKey: "google-key", + githubToken: "github-token", + githubOwner: "veryfront", + githubRepo: "veryfront-code", + githubRef: "refs/heads/main", + noColor: true, + forceColor: true, + denoV8Flags: "--trace-gc", + v8MaxOldSpaceSize: 4096, + veryfrontVersion: "1.2.3", + } satisfies EnvironmentConfig, + ); + }, + ); + }); - const expectedProps: (keyof EnvironmentConfig)[] = [ - "nodeEnv", - "veryfrontEnv", - "veryfrontMode", - "proxyMode", - "debug", - "ci", - "denoTesting", - "perfEnabled", - "apiBaseUrl", - "publicApiBaseUrl", - "apiUrl", - "apiToken", - "projectSlug", - "homeDir", - "xdgConfigHome", - "continuousIntegration", - "sshClient", - "sshTty", - "display", - "waylandDisplay", - "cursorSession", - "serverStartTime", - "vcr", - "experimentalRsc", - "redisUrl", - "cacheDir", - "disableLruInterval", - "appUrl", - "port", - "requestTimeoutMs", - "httpFetchTimeoutMs", - "ssrMaxConcurrentTransforms", - "otelEnabled", - "otelServiceName", - "otelEndpoint", - "otelTracesEndpoint", - "otelMetricsEndpoint", - "otelTracesExporter", - "otelMetricsExporter", - "otelMetricsEnabled", - "openaiApiKey", - "openaiBaseUrl", - "anthropicApiKey", - "anthropicBaseUrl", - "googleApiKey", - "githubToken", - "githubOwner", - "githubRepo", - "githubRef", - "noColor", - "forceColor", - "denoV8Flags", - "v8MaxOldSpaceSize", - "veryfrontVersion", - ]; - - for (const prop of expectedProps) { - expect(prop in env).toBe(true); - } + it("uses documented fallback aliases only when primary variables are absent", async () => { + await withEnv( + { + DENO_ENV: "deno-environment", + VERYFRONT_API_URL: "https://api.example/graphql", + USERPROFILE: "C:\\Users\\veryfront", + VF_CACHE_DIR: "/fallback-cache", + NEXT_PUBLIC_APP_URL: "https://fallback-app.example", + GOOGLE_GENERATIVE_AI_API_KEY: "fallback-google-key", + RELEASE_VERSION: "9.8.7", + }, + async () => { + for ( + const key of [ + "NODE_ENV", + "VERYFRONT_ENV", + "VERYFRONT_API_BASE_URL", + "HOME", + "VERYFRONT_CACHE_DIR", + "APP_URL", + "GOOGLE_API_KEY", + "VERYFRONT_VERSION", + ] + ) { + deleteEnv(key); + } + + _resetEnvironmentConfig(); + const env = refreshEnvironmentConfig(); + expect(env.nodeEnv).toBe("deno-environment"); + expect(env.veryfrontEnv).toBe("deno-environment"); + expect(env.apiBaseUrl).toBe("https://api.example/api"); + expect(env.homeDir).toBe("C:\\Users\\veryfront"); + expect(env.cacheDir).toBe("/fallback-cache"); + expect(env.appUrl).toBe("https://fallback-app.example"); + expect(env.googleApiKey).toBe("fallback-google-key"); + expect(env.veryfrontVersion).toBe("9.8.7"); + }, + ); + }); + + it("parses bounded integer environment values without truncation", async () => { + await withEnv( + { + PORT: " 65535 ", + REQUEST_TIMEOUT_MS: "1234", + VF_HTTP_FETCH_TIMEOUT: "2345", + VF_EXTENSION_SETUP_TIMEOUT_MS: "0", + SSR_MAX_CONCURRENT_TRANSFORMS: "0", + V8_MAX_OLD_SPACE_SIZE: "4096", + }, + async () => { + _resetEnvironmentConfig(); + const env = refreshEnvironmentConfig(); + + expect(env.port).toBe(65535); + expect(env.portSource).toBe("environment"); + expect(env.requestTimeoutMs).toBe(1234); + expect(env.httpFetchTimeoutMs).toBe(2345); + expect(env.extensionSetupTimeoutMs).toBe(0); + expect(env.ssrMaxConcurrentTransforms).toBe(0); + expect(env.v8MaxOldSpaceSize).toBe(4096); + }, + ); + }); + + it("falls back safely for malformed or out-of-range integer values", async () => { + await withEnv( + { + PORT: "65536", + REQUEST_TIMEOUT_MS: "30000ms", + VF_HTTP_FETCH_TIMEOUT: "1.5", + VF_EXTENSION_SETUP_TIMEOUT_MS: "2147483648", + SSR_MAX_CONCURRENT_TRANSFORMS: "1e2", + V8_MAX_OLD_SPACE_SIZE: "-1", + }, + async () => { + _resetEnvironmentConfig(); + const env = refreshEnvironmentConfig(); + + expect(env.port).toBe(3000); + expect(env.portSource).toBe("default"); + expect(env.requestTimeoutMs).toBe(DEFAULT_REQUEST_TIMEOUT_MS); + expect(env.httpFetchTimeoutMs).toBe(30000); + expect(env.extensionSetupTimeoutMs).toBe(30000); + expect(env.ssrMaxConcurrentTransforms).toBe(3); + expect(env.v8MaxOldSpaceSize).toBeUndefined(); + }, + ); + }); + + it("uses standard presence and truth-value semantics for CI and color flags", async () => { + await withEnv( + { + CI: "true", + CONTINUOUS_INTEGRATION: "0", + NO_COLOR: "", + FORCE_COLOR: "0", + }, + async () => { + _resetEnvironmentConfig(); + const env = refreshEnvironmentConfig(); + + expect(env.ci).toBe(true); + expect(env.continuousIntegration).toBe(false); + expect(env.noColor).toBe(true); + expect(env.forceColor).toBe(false); + }, + ); + + await withEnv({ FORCE_COLOR: "2" }, async () => { + _resetEnvironmentConfig(); + expect(refreshEnvironmentConfig().forceColor).toBe(true); + }); }); }); }); diff --git a/src/config/environment-config.ts b/src/config/environment-config.ts index 851fd70cc5..fdcdb7203a 100644 --- a/src/config/environment-config.ts +++ b/src/config/environment-config.ts @@ -1,6 +1,8 @@ import { getEnv, getHostEnv } from "#veryfront/platform/compat/process.ts"; import { getHostTelemetryEnv } from "#veryfront/observability/tracing/telemetry-env.ts"; import { isTruthyEnvValue } from "#veryfront/utils/constants/env.ts"; +import { DEFAULT_DEV_SERVER_PORT, MAX_PORT, MIN_PORT } from "#veryfront/utils/constants/network.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { logger } from "#veryfront/utils/logger/logger.ts"; import { hasEnvLoaded } from "#veryfront/utils/env-loader.ts"; @@ -36,6 +38,7 @@ export interface EnvironmentConfig { experimentalRsc: boolean; + /** Legacy built-in Redis connection used by existing cache consumers. */ redisUrl: string | undefined; cacheDir: string | undefined; disableLruInterval: boolean; @@ -43,6 +46,11 @@ export interface EnvironmentConfig { appUrl: string | undefined; port: number; + /** + * Whether `port` came from a valid `PORT` value or the built-in default. + * Omitted values from custom callers retain the legacy env-override behavior. + */ + portSource?: "default" | "environment"; requestTimeoutMs: number | undefined; httpFetchTimeoutMs: number | undefined; extensionSetupTimeoutMs: number | undefined; @@ -85,121 +93,158 @@ export interface EnvironmentConfig { export const DEFAULT_REQUEST_TIMEOUT_MS = 75_000; /** Default timeout for outgoing HTTP fetch calls (used when VF_HTTP_FETCH_TIMEOUT is set but unparseable) */ const DEFAULT_HTTP_FETCH_TIMEOUT_MS = 30_000; - const DEFAULTS = { apiBaseUrl: "https://api.veryfront.com", - port: 3001, + port: DEFAULT_DEV_SERVER_PORT, ssrMaxConcurrentTransforms: 3, } as const; let _environmentConfig: EnvironmentConfig | null = null; -let envConfigInitializedBeforeEnvLoad = false; let warnedEarlyEnvConfig = false; -function parseNumber(value: string | undefined, defaultVal: number): number { - if (!value) return defaultVal; +function parseBoundedIntegerValue( + value: string | undefined, + min: number, + max = Number.MAX_SAFE_INTEGER, +): number | undefined { + if (value === undefined) return undefined; + + const normalized = value.trim(); + if (!/^\d+$/.test(normalized)) return undefined; - const parsed = parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : defaultVal; + const parsed = Number(normalized); + return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : undefined; } -function readEnvSnapshot(): EnvironmentConfig { - const nodeEnv = getEnv("NODE_ENV") ?? getEnv("DENO_ENV") ?? "development"; - const veryfrontEnv = getEnv("VERYFRONT_ENV") ?? nodeEnv; +function parseBoundedInteger( + value: string | undefined, + defaultVal: number, + min: number, + max = Number.MAX_SAFE_INTEGER, +): number { + return parseBoundedIntegerValue(value, min, max) ?? defaultVal; +} - const requestTimeoutRaw = getEnv("REQUEST_TIMEOUT_MS"); - const httpFetchTimeoutRaw = getEnv("VF_HTTP_FETCH_TIMEOUT"); - const extensionSetupTimeoutRaw = getEnv("VF_EXTENSION_SETUP_TIMEOUT_MS"); - const v8MaxOldSpaceSizeRaw = getEnv("V8_MAX_OLD_SPACE_SIZE"); +type EnvReader = (key: string) => string | undefined; +const readEmptyEnv: EnvReader = () => undefined; - const apiUrl = getEnv("VERYFRONT_API_URL") || undefined; +function readEnvSnapshot( + readEnv: EnvReader = getEnv, + readHostEnv: EnvReader = getHostEnv, + readTelemetryEnv: EnvReader = getHostTelemetryEnv, +): EnvironmentConfig { + const nodeEnv = readEnv("NODE_ENV") ?? readEnv("DENO_ENV") ?? "development"; + const veryfrontEnv = readEnv("VERYFRONT_ENV") ?? nodeEnv; + + const requestTimeoutRaw = readEnv("REQUEST_TIMEOUT_MS"); + const httpFetchTimeoutRaw = readEnv("VF_HTTP_FETCH_TIMEOUT"); + const extensionSetupTimeoutRaw = readEnv("VF_EXTENSION_SETUP_TIMEOUT_MS"); + const v8MaxOldSpaceSizeRaw = readEnv("V8_MAX_OLD_SPACE_SIZE"); + const forceColorRaw = readEnv("FORCE_COLOR"); + const portOverride = parseBoundedIntegerValue(readEnv("PORT"), MIN_PORT, MAX_PORT); + + const apiUrl = readEnv("VERYFRONT_API_URL") || undefined; return { nodeEnv, veryfrontEnv, - veryfrontMode: getEnv("VERYFRONT_MODE") ?? "development", - proxyMode: getHostEnv("PROXY_MODE") === "1", + veryfrontMode: readEnv("VERYFRONT_MODE") ?? "development", + proxyMode: readHostEnv("PROXY_MODE") === "1", - debug: isTruthyEnvValue(getEnv("VERYFRONT_DEBUG")), - ci: getEnv("CI") === "1", - denoTesting: getEnv("DENO_TESTING") === "1", - perfEnabled: getEnv("VERYFRONT_PERF") === "1", + debug: isTruthyEnvValue(readEnv("VERYFRONT_DEBUG")), + ci: isTruthyEnvValue(readEnv("CI")), + denoTesting: readEnv("DENO_TESTING") === "1", + perfEnabled: readEnv("VERYFRONT_PERF") === "1", - apiBaseUrl: getEnv("VERYFRONT_API_BASE_URL") || + apiBaseUrl: readEnv("VERYFRONT_API_BASE_URL") || apiUrl?.replace("/graphql", "/api") || DEFAULTS.apiBaseUrl, - publicApiBaseUrl: getEnv("VERYFRONT_PUBLIC_API_BASE_URL") || + publicApiBaseUrl: readEnv("VERYFRONT_PUBLIC_API_BASE_URL") || DEFAULTS.apiBaseUrl, apiUrl, - apiToken: getEnv("VERYFRONT_API_TOKEN") || undefined, - projectSlug: getEnv("VERYFRONT_PROJECT_SLUG") || undefined, + apiToken: readEnv("VERYFRONT_API_TOKEN") || undefined, + projectSlug: readEnv("VERYFRONT_PROJECT_SLUG") || undefined, - homeDir: getEnv("HOME") || getEnv("USERPROFILE") || undefined, - xdgConfigHome: getEnv("XDG_CONFIG_HOME") || undefined, + homeDir: readEnv("HOME") || readEnv("USERPROFILE") || undefined, + xdgConfigHome: readEnv("XDG_CONFIG_HOME") || undefined, - continuousIntegration: !!getEnv("CONTINUOUS_INTEGRATION"), - sshClient: getEnv("SSH_CLIENT") || undefined, - sshTty: getEnv("SSH_TTY") || undefined, - display: getEnv("DISPLAY") || undefined, - waylandDisplay: getEnv("WAYLAND_DISPLAY") || undefined, - cursorSession: getEnv("CURSOR_SESSION") || undefined, - serverStartTime: getEnv("VERYFRONT_SERVER_START_TIME") || undefined, - vcr: getEnv("VCR") || undefined, + continuousIntegration: isTruthyEnvValue(readEnv("CONTINUOUS_INTEGRATION")), + sshClient: readEnv("SSH_CLIENT") || undefined, + sshTty: readEnv("SSH_TTY") || undefined, + display: readEnv("DISPLAY") || undefined, + waylandDisplay: readEnv("WAYLAND_DISPLAY") || undefined, + cursorSession: readEnv("CURSOR_SESSION") || undefined, + serverStartTime: readEnv("VERYFRONT_SERVER_START_TIME") || undefined, + vcr: readEnv("VCR") || undefined, - experimentalRsc: getEnv("VERYFRONT_EXPERIMENTAL_RSC") === "1", + experimentalRsc: readEnv("VERYFRONT_EXPERIMENTAL_RSC") === "1", - redisUrl: getEnv("REDIS_URL") || undefined, - cacheDir: getEnv("VERYFRONT_CACHE_DIR") || getEnv("VF_CACHE_DIR") || undefined, - disableLruInterval: getEnv("VF_DISABLE_LRU_INTERVAL") === "1", + redisUrl: readEnv("REDIS_URL") || undefined, + cacheDir: readEnv("VERYFRONT_CACHE_DIR") || readEnv("VF_CACHE_DIR") || undefined, + disableLruInterval: readEnv("VF_DISABLE_LRU_INTERVAL") === "1", - appUrl: getEnv("APP_URL") || getEnv("NEXT_PUBLIC_APP_URL") || undefined, + appUrl: readEnv("APP_URL") || readEnv("NEXT_PUBLIC_APP_URL") || undefined, - port: parseNumber(getEnv("PORT"), DEFAULTS.port), + port: portOverride ?? DEFAULTS.port, + portSource: portOverride === undefined ? "default" : "environment", requestTimeoutMs: requestTimeoutRaw - ? parseNumber(requestTimeoutRaw, DEFAULT_REQUEST_TIMEOUT_MS) + ? parseBoundedInteger( + requestTimeoutRaw, + DEFAULT_REQUEST_TIMEOUT_MS, + 1, + MAX_TIMER_DELAY_MS, + ) : undefined, httpFetchTimeoutMs: httpFetchTimeoutRaw - ? parseNumber(httpFetchTimeoutRaw, DEFAULT_HTTP_FETCH_TIMEOUT_MS) + ? parseBoundedInteger( + httpFetchTimeoutRaw, + DEFAULT_HTTP_FETCH_TIMEOUT_MS, + 1, + MAX_TIMER_DELAY_MS, + ) : undefined, extensionSetupTimeoutMs: extensionSetupTimeoutRaw - ? parseNumber(extensionSetupTimeoutRaw, 30_000) + ? parseBoundedInteger(extensionSetupTimeoutRaw, 30_000, 0, MAX_TIMER_DELAY_MS) : undefined, - ssrMaxConcurrentTransforms: parseNumber( - getEnv("SSR_MAX_CONCURRENT_TRANSFORMS"), + ssrMaxConcurrentTransforms: parseBoundedInteger( + readEnv("SSR_MAX_CONCURRENT_TRANSFORMS"), DEFAULTS.ssrMaxConcurrentTransforms, + 0, ), - otelEnabled: isTruthyEnvValue(getHostTelemetryEnv("VERYFRONT_OTEL")) || - isTruthyEnvValue(getHostTelemetryEnv("OTEL_TRACES_ENABLED")), - otelServiceName: getHostTelemetryEnv("OTEL_SERVICE_NAME") || undefined, - otelEndpoint: getHostTelemetryEnv("OTEL_EXPORTER_OTLP_ENDPOINT") || undefined, - otelTracesEndpoint: getHostTelemetryEnv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") || undefined, - otelMetricsEndpoint: getHostTelemetryEnv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") || undefined, - otelTracesExporter: getHostTelemetryEnv("OTEL_TRACES_EXPORTER") || undefined, - otelMetricsExporter: getHostTelemetryEnv("OTEL_METRICS_EXPORTER") || undefined, - otelHeaders: getHostTelemetryEnv("OTEL_EXPORTER_OTLP_HEADERS") || undefined, - otelMetricsEnabled: isTruthyEnvValue(getHostTelemetryEnv("OTEL_METRICS_ENABLED")), - - openaiApiKey: getEnv("OPENAI_API_KEY") || undefined, - openaiBaseUrl: getEnv("OPENAI_BASE_URL") || undefined, - anthropicApiKey: getEnv("ANTHROPIC_API_KEY") || undefined, - anthropicBaseUrl: getEnv("ANTHROPIC_BASE_URL") || undefined, - googleApiKey: getEnv("GOOGLE_API_KEY") || getEnv("GOOGLE_GENERATIVE_AI_API_KEY") || undefined, - - githubToken: getEnv("GITHUB_TOKEN") || undefined, - githubOwner: getEnv("GITHUB_OWNER") || undefined, - githubRepo: getEnv("GITHUB_REPO") || undefined, - githubRef: getEnv("GITHUB_REF") || undefined, - - noColor: !!getEnv("NO_COLOR"), - forceColor: !!getEnv("FORCE_COLOR"), - - denoV8Flags: getEnv("DENO_V8_FLAGS") ?? "", + otelEnabled: isTruthyEnvValue(readTelemetryEnv("VERYFRONT_OTEL")) || + isTruthyEnvValue(readTelemetryEnv("OTEL_TRACES_ENABLED")), + otelServiceName: readTelemetryEnv("OTEL_SERVICE_NAME") || undefined, + otelEndpoint: readTelemetryEnv("OTEL_EXPORTER_OTLP_ENDPOINT") || undefined, + otelTracesEndpoint: readTelemetryEnv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") || undefined, + otelMetricsEndpoint: readTelemetryEnv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") || undefined, + otelTracesExporter: readTelemetryEnv("OTEL_TRACES_EXPORTER") || undefined, + otelMetricsExporter: readTelemetryEnv("OTEL_METRICS_EXPORTER") || undefined, + otelHeaders: readTelemetryEnv("OTEL_EXPORTER_OTLP_HEADERS") || undefined, + otelMetricsEnabled: isTruthyEnvValue(readTelemetryEnv("OTEL_METRICS_ENABLED")), + + openaiApiKey: readEnv("OPENAI_API_KEY") || undefined, + openaiBaseUrl: readEnv("OPENAI_BASE_URL") || undefined, + anthropicApiKey: readEnv("ANTHROPIC_API_KEY") || undefined, + anthropicBaseUrl: readEnv("ANTHROPIC_BASE_URL") || undefined, + googleApiKey: readEnv("GOOGLE_API_KEY") || + readEnv("GOOGLE_GENERATIVE_AI_API_KEY") || + undefined, + + githubToken: readEnv("GITHUB_TOKEN") || undefined, + githubOwner: readEnv("GITHUB_OWNER") || undefined, + githubRepo: readEnv("GITHUB_REPO") || undefined, + githubRef: readEnv("GITHUB_REF") || undefined, + + noColor: readEnv("NO_COLOR") !== undefined, + forceColor: forceColorRaw !== undefined && forceColorRaw !== "" && forceColorRaw !== "0", + + denoV8Flags: readEnv("DENO_V8_FLAGS") ?? "", v8MaxOldSpaceSize: v8MaxOldSpaceSizeRaw - ? parseNumber(v8MaxOldSpaceSizeRaw, 0) || undefined + ? parseBoundedInteger(v8MaxOldSpaceSizeRaw, 0, 1) || undefined : undefined, - veryfrontVersion: getEnv("VERYFRONT_VERSION") || getEnv("RELEASE_VERSION") || undefined, + veryfrontVersion: readEnv("VERYFRONT_VERSION") || readEnv("RELEASE_VERSION") || undefined, }; } @@ -207,18 +252,15 @@ export function initEnvironmentConfig(): EnvironmentConfig { if (_environmentConfig) return _environmentConfig; if (!hasEnvLoaded()) { - envConfigInitializedBeforeEnvLoad = true; - return readEnvSnapshot(); + return Object.freeze(readEnvSnapshot()); } _environmentConfig = Object.freeze(readEnvSnapshot()); - envConfigInitializedBeforeEnvLoad = false; return _environmentConfig; } export function refreshEnvironmentConfig(): EnvironmentConfig { _environmentConfig = Object.freeze(readEnvSnapshot()); - envConfigInitializedBeforeEnvLoad = false; return _environmentConfig; } @@ -237,10 +279,6 @@ function warnEarlyAccess(): void { } export function getEnvironmentConfig(): EnvironmentConfig { - // If cached and env has loaded since init, refresh to pick up .env values - if (_environmentConfig && envConfigInitializedBeforeEnvLoad && hasEnvLoaded()) { - return refreshEnvironmentConfig(); - } if (_environmentConfig) { return _environmentConfig; } @@ -248,7 +286,7 @@ export function getEnvironmentConfig(): EnvironmentConfig { // Env not loaded yet - return uncached snapshot with warning if (!hasEnvLoaded()) { warnEarlyAccess(); - return readEnvSnapshot(); + return Object.freeze(readEnvSnapshot()); } return initEnvironmentConfig(); @@ -261,25 +299,31 @@ export function isEnvironmentConfigInitialized(): boolean { export function createTestEnvironmentConfig( overrides: Partial = {}, ): EnvironmentConfig { - const base = _environmentConfig ?? readEnvSnapshot(); + const base = readEnvSnapshot(readEmptyEnv, readEmptyEnv, readEmptyEnv); + const portSource = overrides.portSource ?? + (Object.hasOwn(overrides, "port") ? "environment" : base.portSource); return { ...base, nodeEnv: "test", + veryfrontEnv: "test", debug: false, ci: false, denoTesting: false, ...overrides, + portSource, }; } export function _setEnvironmentConfigForTesting(env: Partial): void { - const base = _environmentConfig ?? readEnvSnapshot(); - _environmentConfig = Object.freeze({ ...base, ...env }); + const base = _environmentConfig ?? + readEnvSnapshot(readEmptyEnv, readEmptyEnv, readEmptyEnv); + const portSource = env.portSource ?? + (Object.hasOwn(env, "port") ? "environment" : base.portSource); + _environmentConfig = Object.freeze({ ...base, ...env, portSource }); } export function _resetEnvironmentConfig(): void { _environmentConfig = null; - envConfigInitializedBeforeEnvLoad = false; warnedEarlyEnvConfig = false; } diff --git a/src/config/index.ts b/src/config/index.ts index 3b57dc6ae1..9aab2c2c78 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,6 +11,13 @@ export { getConfig, type GetConfigOptions, } from "./loader.ts"; +export { + type ConfigFileExists, + findVeryfrontConfigFile, + VERYFRONT_CONFIG_FILES, + type VeryfrontConfigFile, + type VeryfrontConfigFileName, +} from "./config-files.ts"; export { defineConfig, defineConfigWithEnv, mergeConfigs } from "./define-config.ts"; export { getApiTokenEnv, isCiEnv, isDenoTestingEnv, isRscExperimentalEnabled } from "./env.ts"; @@ -45,8 +52,6 @@ export { DEFAULT_METRICS_COLLECT_INTERVAL_MS, DEFAULT_PORT, DEFAULT_PREFETCH_DELAY_MS, - DEFAULT_REDIS_BATCH_DELETE_SIZE, - DEFAULT_REDIS_SCAN_COUNT, DEFAULT_TIMEOUT_MS, type DefaultConfig, defaultConfig, @@ -66,5 +71,4 @@ export { HTTP_DEFAULTS, LOCALHOST, LOCALHOST_URLS, - REDIS_DEFAULTS, } from "./network-defaults.ts"; diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index 3cb0fcee7b..24a9e05287 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -1,28 +1,189 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; -import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { afterAll, afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { waitFor } from "#veryfront/testing/deno-compat.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; import { + __getHostedConfigFlightStateForTests, + __getHostedConfigSourceReadStateForTests, + __getTrustedConfigFlightStateForTests, + __observePromiseForTests, + __setHostedConfigEvaluatorForTests, clearConfigCache, + evaluateHostedConfigSource, getCachedConfigSync, getConfig, + getConfigWithProvenance, + getHostedConfig, mergeConfigs, rewriteBareVeryfrontConfigImports, transpileConfigSourceForImport, } from "./loader.ts"; import { createMockAdapter } from "../platform/adapters/mock.ts"; import { VeryfrontError } from "#veryfront/errors"; +import { + DeclarativeConfigEvaluationError, + prepareDeclarativeConfigContext, +} from "./declarative-evaluator.ts"; +import { DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS } from "./declarative-evaluator-worker-runner.ts"; import { getCurrentRequestContext, runWithRequestContext, } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; +import { + deleteEnv, + getEnvOverlayStorage, + getHostEnv, + setEnv, +} from "#veryfront/platform/compat/process.ts"; +import { ESBUILD_WASM_URL } from "#veryfront/platform/compat/esbuild-shared.ts"; +import { MAX_HOSTED_RENDER_CACHE_ENTRIES } from "./defaults.ts"; + +const TestObjectDefineProperty = Object.defineProperty; +const TestObjectCreate = Object.create; +const TestObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const TestObjectGetPrototypeOf = Object.getPrototypeOf; +const TestReflectApply = Reflect.apply; +const TestReflectDeleteProperty = Reflect.deleteProperty; +const TestReflectOwnKeys = Reflect.ownKeys; + +function replacePropertyForTest( + target: object, + key: PropertyKey, + replacement: PropertyDescriptor, +): () => void { + const original = TestObjectGetOwnPropertyDescriptor(target, key); + if (!original) throw new Error(`Expected an own descriptor for ${String(key)}`); + TestObjectDefineProperty(target, key, { + ...original, + ...replacement, + }); + return () => { + TestObjectDefineProperty(target, key, original); + }; +} + +function definePropertyForTest( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, +): () => void { + const original = TestObjectGetOwnPropertyDescriptor(target, key); + TestObjectDefineProperty(target, key, { + configurable: true, + ...descriptor, + }); + return () => { + if (original) TestObjectDefineProperty(target, key, original); + else TestReflectApply(TestReflectDeleteProperty, Reflect, [target, key]); + }; +} + +function defineNullPrototypeAccessorForTest( + target: object, + key: PropertyKey, + getter: () => unknown, + setter: (value?: unknown) => unknown, +): () => void { + const original = TestObjectGetOwnPropertyDescriptor(target, key); + if (original) throw new Error(`Expected no own descriptor for ${String(key)}`); + const descriptor = TestReflectApply( + TestObjectCreate, + Object, + [null], + ) as PropertyDescriptor; + descriptor.get = getter; + descriptor.set = setter; + descriptor.enumerable = false; + descriptor.configurable = true; + TestReflectApply(TestObjectDefineProperty, Object, [ + target, + key, + descriptor, + ]); + return () => { + TestReflectApply(TestReflectDeleteProperty, Reflect, [target, key]); + }; +} function setup() { clearConfigCache(); return createMockAdapter(); } +function configCandidateNotFound(path: string): Error { + return Object.assign(new Error(`File not found: ${path}`), { + code: "ENOENT", + }); +} + +async function waitForHostedFlightState( + expected: Readonly<{ flights: number; waiters: number }>, +): Promise { + await waitFor( + () => { + const current = __getHostedConfigFlightStateForTests(); + return current.flights === expected.flights && + current.waiters === expected.waiters; + }, + { + interval: 10, + message: `Expected hosted flight state ${JSON.stringify(expected)}`, + }, + ); +} + +async function waitForHostedSourceReadState( + expected: Readonly<{ + active: number; + queued: number; + flights: number; + waiters: number; + }>, +): Promise { + await waitFor( + () => { + const current = __getHostedConfigSourceReadStateForTests(); + return current.active === expected.active && + current.queued === expected.queued && + current.flights === expected.flights && + current.waiters === expected.waiters; + }, + { + interval: 10, + message: `Expected hosted source-read state ${JSON.stringify(expected)}`, + }, + ); +} + +async function waitForTrustedFlightCount(expected: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const current = __getTrustedConfigFlightStateForTests(); + if (current.flights === expected) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error( + `Timed out waiting for ${expected} trusted config flights; observed ${__getTrustedConfigFlightStateForTests().flights}`, + ); +} + describe("config/loader", () => { + afterEach(async () => { + __setHostedConfigEvaluatorForTests(); + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); + describe("transpileConfigSourceForImport", () => { afterAll(async () => { await stopEsbuild(); @@ -58,17 +219,20 @@ export default config as const; await stopEsbuild(); }); - it("rewrites bare veryfront specifiers to a loadable shim", () => { - const rewritten = rewriteBareVeryfrontConfigImports( + it("rewrites bare veryfront specifiers to a loadable shim", async () => { + const rewritten = await rewriteBareVeryfrontConfigImports( 'import { defineConfig } from "veryfront";\nexport default defineConfig({});', ); assert(!rewritten.includes('"veryfront"'), "bare specifier must be replaced"); - assert(rewritten.includes("data:text/javascript,"), "specifier must point at the shim"); + assert( + rewritten.includes("data:text/javascript;base64,"), + "specifier must point at the shim", + ); }); - it("handles single quotes and leaves other specifiers untouched", () => { - const rewritten = rewriteBareVeryfrontConfigImports( + it("handles single quotes and leaves other specifiers untouched", async () => { + const rewritten = await rewriteBareVeryfrontConfigImports( "import { defineConfig } from 'veryfront';\nimport other from './local.ts';\nimport 'veryfront';", ); @@ -76,10 +240,23 @@ export default config as const; assert(rewritten.includes("./local.ts"), "relative imports must stay untouched"); }); - it("does not rewrite veryfront subpath or lookalike specifiers", () => { + it("does not rewrite veryfront subpath or lookalike specifiers", async () => { const source = 'import { a } from "veryfront/head";\nimport { b } from "not-veryfront";\nconst s = "veryfront";'; - assertEquals(rewriteBareVeryfrontConfigImports(source), source); + assertEquals(await rewriteBareVeryfrontConfigImports(source), source); + }); + + it("does not rewrite import-like text outside module declarations", async () => { + const source = [ + "const quoted = 'from \"veryfront\"';", + "const sideEffect = 'import \"veryfront\"';", + 'const pattern = /from "veryfront"/;', + 'const template = `import "veryfront"`;', + '// import "veryfront"', + '/* export { defineConfig } from "veryfront" */', + ].join("\n"); + + assertEquals(await rewriteBareVeryfrontConfigImports(source), source); }); it("produces a module whose defineConfig behaves as identity end to end", async () => { @@ -89,7 +266,7 @@ export default config as const; ].join("\n"); const transpiled = await transpileConfigSourceForImport(source, "/app/veryfront.config.ts"); - const rewritten = rewriteBareVeryfrontConfigImports(transpiled); + const rewritten = await rewriteBareVeryfrontConfigImports(transpiled); const module = await import(`data:application/javascript;base64,${btoa(rewritten)}`) as { default: { projectSlug: string; title: string }; }; @@ -98,140 +275,2957 @@ export default config as const; assertEquals(module.default.title, "Shim"); }); - it("shims defineConfigWithEnv with a working environment factory", async () => { - const source = [ - 'import { defineConfigWithEnv } from "veryfront";', - "export default defineConfigWithEnv((env) => ({ title: `env:${env}` }));", - ].join("\n"); + it("shims defineConfigWithEnv with a working environment factory", async () => { + const source = [ + 'import { defineConfigWithEnv } from "veryfront";', + "export default defineConfigWithEnv((env) => ({ title: `env:${env}` }));", + ].join("\n"); + + const transpiled = await transpileConfigSourceForImport(source, "/app/veryfront.config.ts"); + const rewritten = await rewriteBareVeryfrontConfigImports(transpiled); + const module = await import(`data:application/javascript;base64,${btoa(rewritten)}`) as { + default: { title: string }; + }; + + assert(module.default.title.startsWith("env:"), "factory must receive an env name"); + }); + + it("bridges getEnv through the active environment scope", async () => { + setEnv("VERYFRONT_CONFIG_SHIM_TEST", "scoped-value"); + const source = [ + 'import { defineConfig, getEnv } from "veryfront";', + 'export default defineConfig({ title: getEnv("VERYFRONT_CONFIG_SHIM_TEST") });', + ].join("\n"); + + const transpiled = await transpileConfigSourceForImport(source, "/app/veryfront.config.ts"); + const rewritten = await rewriteBareVeryfrontConfigImports(transpiled); + const module = await import(`data:application/javascript;base64,${btoa(rewritten)}`) as { + default: { title: string }; + }; + + assertEquals(module.default.title, "scoped-value"); + }); + }); + + describe("clearConfigCache", () => { + it("should not throw when called on empty cache", () => { + clearConfigCache(); + }); + + it("should invalidate previously cached configs", async () => { + const adapter = setup(); + + const config1 = await getConfig("/test-project", adapter); + assert(config1 !== null); + + const config2 = await getConfig("/test-project", adapter); + assertEquals(config2, config1); + + clearConfigCache(); + const config3 = await getConfig("/test-project", adapter); + assert(config3 !== null); + assert(config3 !== config1, "Expected new object after cache clear"); + }); + + it("does not repopulate the cache from a load invalidated in flight", async () => { + const adapter = setup(); + const projectDir = "/in-flight-clear"; + const started = Promise.withResolvers(); + const resume = Promise.withResolvers(); + let firstCheck = true; + + adapter.fs.exists = async () => { + if (firstCheck) { + firstCheck = false; + started.resolve(); + await resume.promise; + } + return false; + }; + + const staleRequest = getConfig(projectDir, adapter); + await started.promise; + clearConfigCache(); + const fresh = await getConfig(projectDir, adapter); + resume.resolve(); + const stale = await staleRequest; + + assertStrictEquals(getCachedConfigSync(projectDir), fresh); + assert( + stale !== fresh, + "a cleared revision must not join or replace the fresh revision's flight", + ); + }); + }); + + describe("getCachedConfigSync", () => { + it("should return null for uncached project", () => { + clearConfigCache(); + assertEquals(getCachedConfigSync("/nonexistent-project"), null); + }); + + it("returns the config cached for a project directory", async () => { + const adapter = setup(); + const config = await getConfig("/cached-project", adapter); + + assertEquals(getCachedConfigSync("/cached-project"), config); + }); + + it("should return null after cache is cleared", async () => { + const adapter = setup(); + + await getConfig("/cached-project", adapter); + clearConfigCache(); + + assertEquals(getCachedConfigSync("/cached-project"), null); + }); + }); + + describe("getConfig", () => { + it("should return default config when no config file exists", async () => { + const adapter = setup(); + + const config = await getConfig("/empty-project", adapter); + assert(config !== null); + assertEquals(config.title, "Veryfront App"); + assertEquals(config.description, "Built with Veryfront"); + assertEquals(config.build?.outDir, "dist"); + assertEquals(config.dev?.port, 3000); + assertEquals(config.dev?.host, "localhost"); + assertEquals(config.dev?.open, false); + assertEquals(config.client?.moduleResolution, "cdn"); + assertEquals(config.client?.cdn?.provider, "esm.sh"); + }); + + it("reports explicit default provenance when no config file exists", async () => { + const adapter = setup(); + + const result = await getConfigWithProvenance( + "/empty-project-provenance", + adapter, + ); + + assertEquals(result.provenance, { kind: "defaults" }); + assertEquals(result.config.title, "Veryfront App"); + }); + + it("reports file provenance even when a present config matches default values", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ + prefix: "vf-config-provenance-", + }); + const configPath = `${projectDir}/veryfront.config.js`; + const source = [ + "export default {", + ' title: "Veryfront App",', + ' dev: { port: 3000, host: "localhost", hmr: false },', + "};", + ].join("\n"); + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + const result = await getConfigWithProvenance(projectDir, adapter); + + assertEquals(result.provenance, { + kind: "file", + configFile: "veryfront.config.js", + }); + assertEquals(result.config.dev?.port, 3000); + assertEquals(result.config.dev?.host, "localhost"); + assertEquals(result.config.dev?.hmr, false); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("selects trusted virtual config from one explicit read outcome", async () => { + const adapter = setup(); + const reads: string[] = []; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: () => { + throw new Error("explicit provenance must not use an exists heuristic"); + }, + readFile: async (path: string) => { + reads.push(path); + if (path === "/veryfront.config.ts") { + return [ + "export default {", + ' title: "Veryfront App",', + ' dev: { port: 3000, host: "localhost", hmr: false },', + "};", + ].join("\n"); + } + throw configCandidateNotFound(path); + }, + }); + + const result = await getConfigWithProvenance( + "/explicit-virtual-provenance", + adapter, + { cacheKey: "explicit-virtual-provenance" }, + ); + + assertEquals(reads, [ + "/veryfront.config.js", + "/veryfront.config.ts", + ]); + assertEquals(result.provenance, { + kind: "file", + configFile: "veryfront.config.ts", + }); + assertEquals(result.config.dev?.port, 3000); + assertEquals(result.config.dev?.hmr, false); + }); + + it("propagates trusted virtual backend errors instead of reporting absence", async () => { + const adapter = setup(); + const backendError = new Error("virtual config backend unavailable"); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: () => { + throw new Error("trusted virtual loads must not use exists"); + }, + readFile: () => Promise.reject(backendError), + }); + + for ( + const load of [ + () => getConfig("/explicit-virtual-error", adapter), + () => + getConfigWithProvenance( + "/explicit-virtual-error", + adapter, + ), + ] + ) { + const error = await assertRejects(load); + assertStrictEquals(error, backendError); + } + }); + + it("shares one authoritative virtual read across both loader APIs", async () => { + const adapter = setup(); + const readStarted = Promise.withResolvers(); + const resumeRead = Promise.withResolvers(); + const sourceContext = { + productionMode: true, + releaseId: "authoritative-read-release", + } as const; + let reads = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: () => { + throw new Error("trusted virtual loads must not use exists"); + }, + readFile: async () => { + reads += 1; + readStarted.resolve(); + await resumeRead.promise; + return 'export default { title: "REMOTE" };'; + }, + }); + + await runWithRequestContext( + { + projectSlug: "authoritative-read-project", + projectId: "authoritative-read-project", + token: "token", + productionMode: true, + releaseId: sourceContext.releaseId, + }, + async () => { + const options = { + cacheKey: "authoritative-read-project", + sourceContext, + }; + const ordinaryRequest = getConfig( + "/authoritative-read-project", + adapter, + options, + ); + await readStarted.promise; + const explicitRequest = getConfigWithProvenance( + "/authoritative-read-project", + adapter, + options, + ); + resumeRead.resolve(); + + const [ordinary, explicit] = await Promise.all([ + ordinaryRequest, + explicitRequest, + ]); + assertEquals(ordinary.title, "REMOTE"); + assertEquals(explicit.provenance, { + kind: "file", + configFile: "veryfront.config.js", + }); + assertStrictEquals(explicit.config, ordinary); + assertEquals(reads, 1); + + const cached = await getConfig( + "/authoritative-read-project", + adapter, + options, + ); + assertStrictEquals(cached, ordinary); + assertEquals(reads, 1); + }, + ); + }); + + it("should return cached config on subsequent calls", async () => { + const adapter = setup(); + + const config1 = await getConfig("/cached-test", adapter); + const config2 = await getConfig("/cached-test", adapter); + + assertEquals(config1, config2); + }); + + it("should cache separately for different project directories", async () => { + const adapter = setup(); + + const configA = await getConfig("/project-a", adapter); + const configB = await getConfig("/project-b", adapter); + + assert(configA !== null); + assert(configB !== null); + assertEquals(configA.title, "Veryfront App"); + assertEquals(configB.title, "Veryfront App"); + }); + + describe("trusted config single-flight", () => { + it("executes one exact concurrent config module once and shares its identity", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ + prefix: "vf-config-single-flight-", + }); + const configPath = `${projectDir}/veryfront.config.js`; + const counterKey = `__veryfront_config_single_flight_${ + crypto.randomUUID().replaceAll("-", "_") + }`; + const source = [ + `const key = ${JSON.stringify(counterKey)};`, + "globalThis[key] = (globalThis[key] ?? 0) + 1;", + "export default { title: `execution-${globalThis[key]}` };", + ].join("\n"); + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + const [first, second, third] = await Promise.all([ + getConfig(projectDir, adapter), + getConfig(projectDir, adapter), + getConfig(projectDir, adapter), + ]); + + assertEquals(first.title, "execution-1"); + assertStrictEquals(second, first); + assertStrictEquals(third, first); + assertEquals( + (globalThis as Record)[counterKey], + 1, + ); + await waitForTrustedFlightCount(0); + } finally { + delete (globalThis as Record)[counterKey]; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("evicts rejected flights so a later request can retry", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ + prefix: "vf-config-single-flight-retry-", + }); + const configPath = `${projectDir}/veryfront.config.js`; + const counterKey = `__veryfront_config_retry_${crypto.randomUUID().replaceAll("-", "_")}`; + const source = [ + `const key = ${JSON.stringify(counterKey)};`, + "const attempt = (globalThis[key] ?? 0) + 1;", + "globalThis[key] = attempt;", + 'if (attempt === 1) throw new Error("first execution failed");', + "export default { title: `execution-${attempt}` };", + ].join("\n"); + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + await assertRejects( + () => getConfig(projectDir, adapter), + Error, + "Failed to load veryfront.config.js", + ); + await waitForTrustedFlightCount(0); + + const recovered = await getConfig(projectDir, adapter); + + assertEquals(recovered.title, "execution-2"); + assertEquals( + (globalThis as Record)[counterKey], + 2, + ); + } finally { + delete (globalThis as Record)[counterKey]; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("bounds distinct concurrent loads and recovers capacity after they drain", async () => { + const adapter = setup(); + const gate = Promise.withResolvers(); + const { maxFlights } = __getTrustedConfigFlightStateForTests(); + adapter.fs.exists = async () => { + await gate.promise; + return false; + }; + + const pending = Array.from( + { length: maxFlights }, + (_, index) => getConfig(`/bounded-flight-${index}`, adapter), + ); + + try { + await waitForTrustedFlightCount(maxFlights); + const error = await assertRejects( + () => getConfig("/bounded-flight-overflow", adapter), + VeryfrontError, + ) as VeryfrontError; + assertEquals(error.slug, "service-overloaded"); + } finally { + gate.resolve(); + await Promise.allSettled(pending); + } + + await waitForTrustedFlightCount(0); + const recovered = await getConfig("/bounded-flight-recovered", adapter); + assertEquals(recovered.title, "Veryfront App"); + }); + }); + + it("should load and validate a JS config file", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ prefix: "vf-config-js-" }); + const configPath = `${projectDir}/veryfront.config.js`; + const source = 'export default { title: "JS Project" };'; + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + const config = await getConfig(projectDir, adapter); + assertEquals(config.title, "JS Project"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("loads config paths containing URL-significant characters", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ prefix: "vf config #project-" }); + const configPath = `${projectDir}/veryfront.config.js`; + const source = 'export default { title: "Encoded Path Project" };'; + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + const config = await getConfig(projectDir, adapter); + assertEquals(config.title, "Encoded Path Project"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("loads canonical source integration restrictions", async () => { + const adapter = setup(); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + }); + const projectDir = "/typed-integration-config"; + const configPath = "/veryfront.config.ts"; + const source = [ + 'import { defineConfig } from "veryfront";', + 'export default defineConfig({ integrations: { allow: { linear: { allowedTools: ["search_issues"] } } } });', + ].join("\n"); + + adapter.fs.files.set(configPath, source); + + const config = await getConfig(projectDir, adapter); + assertEquals(config.integrations, { + allow: { linear: { allowedTools: ["search_issues"] } }, + }); + }); + + describe("evaluateHostedConfigSource", () => { + it("rejects host APIs and dynamic or relative imports without executing them", async () => { + clearConfigCache(); + const marker = "__veryfrontExactHostedConfigMutation"; + const host = globalThis as Record; + const previousMarker = Object.getOwnPropertyDescriptor(host, marker); + Object.defineProperty(host, marker, { + configurable: true, + value: 0, + writable: true, + }); + const sources = [ + `const hidden = false || eval("globalThis.${marker} = 1"); export default {};`, + 'const hidden = true ? null : import("./evil.ts"); export default {};', + 'import { defineConfig } from "./local.ts"; export default defineConfig({});', + ]; + + try { + for (let index = 0; index < sources.length; index += 1) { + const error = await assertRejects( + () => + evaluateHostedConfigSource({ + cacheKey: `exact-hostile-${index}`, + source: { + source: sources[index]!, + fileName: "veryfront.config.ts", + }, + environmentName: "release", + environment: {}, + }), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(error.slug, "config-parse-error"); + assert(error.cause instanceof DeclarativeConfigEvaluationError); + } + assertEquals(host[marker], 0); + } finally { + if (previousMarker) Object.defineProperty(host, marker, previousMarker); + else delete host[marker]; + } + }); + + it("binds exact release evaluation to an empty tenant environment", async () => { + clearConfigCache(); + const envKey = "VERYFRONT_EXACT_CONFIG_HOST_SECRET_TEST"; + const previousValue = getHostEnv(envKey); + setEnv(envKey, "host-secret"); + + try { + const config = await evaluateHostedConfigSource({ + cacheKey: "exact-release-empty-environment", + source: { + fileName: "veryfront.config.ts", + source: ` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: \`\${environmentName}:\${getEnv(${JSON.stringify(envKey)}) ?? "missing"}\`, + })); + `, + }, + environmentName: "release", + environment: {}, + }); + + assertEquals(config.title, "release:missing"); + } finally { + if (previousValue === undefined) deleteEnv(envKey); + else setEnv(envKey, previousValue); + } + }); + + it("rejects a pre-aborted request before invoking the worker evaluator", async () => { + clearConfigCache(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + return {}; + }); + const controller = new AbortController(); + controller.abort(); + + const error = await assertRejects( + () => + evaluateHostedConfigSource({ + cacheKey: "exact-release-pre-aborted", + source: { + source: "export default {};", + fileName: "veryfront.config.ts", + }, + environmentName: "release", + environment: {}, + signal: controller.signal, + }), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.reason, "worker-aborted"); + assertEquals(evaluations, 0); + }); + + it("returns deeply frozen defaults for an absent exact source", async () => { + clearConfigCache(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + return {}; + }); + + const config = await evaluateHostedConfigSource({ + cacheKey: "exact-release-defaults", + source: null, + environmentName: "release", + environment: {}, + }); + const visited = new WeakSet(); + const assertDeeplyFrozen = (value: unknown): void => { + if ( + (typeof value !== "object" && typeof value !== "function") || + value === null || visited.has(value) + ) return; + visited.add(value); + assert(Object.isFrozen(value)); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && "value" in descriptor) { + assertDeeplyFrozen(descriptor.value); + } + } + }; + + assertDeeplyFrozen(config); + assertEquals(evaluations, 0); + assertThrows(() => { + (config as { title?: string }).title = "mutated"; + }, TypeError); + }); + }); + + it("evaluates hosted multi-project config in the real worker with tenant env", async () => { + const adapter = setup(); + const sourceContext = { + productionMode: false, + branch: "feature/hosted-config", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: { TENANT: "tenant-value" }, + }); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return ` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: \`\${environmentName}:\${getEnv("TENANT") ?? "missing"}\`, + })); + `; + }, + }); + + const config = await runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted-worker-config", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ); + + assertEquals(config.title, "preview:tenant-value"); + assert(Object.isFrozen(config)); + assert(Object.isFrozen(config.build)); + assertThrows(() => { + (config as { title: string }).title = "mutated"; + }, TypeError); + }); + + it("keeps hosted loader state and immutable results independent of poisoned primordials", async () => { + const adapter = setup(); + const sourceContext = { + productionMode: false, + branch: "feature/primordial-capture", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return 'export default { title: "source" };'; + }, + }); + let poisonCalls = 0; + const poisoned = (): never => { + poisonCalls += 1; + throw new Error("ambient primordial invoked after hosted boundary import"); + }; + const snapshot = { + title: "captured", + theme: { + colors: { + primary: "#123456", + }, + }, + }; + const schemaInputObjects: readonly object[] = [ + snapshot, + snapshot.theme, + snapshot.theme.colors, + ]; + let restore: Array<() => void> = []; + const installPoisoning = (): void => { + const envOverlayStore = getEnvOverlayStorage()?.getStore(); + const poisonMapMethod = (original: (...args: never[]) => unknown) => + function (this: unknown, ...args: unknown[]): unknown { + if (this === envOverlayStore) { + return TestReflectApply(original, this, args); + } + return poisoned(); + }; + const mapSizeGetter = TestObjectGetOwnPropertyDescriptor( + Map.prototype, + "size", + )?.get; + if (!mapSizeGetter) throw new Error("Expected Map size getter"); + restore = [ + replacePropertyForTest(Map.prototype, "clear", { + value: poisonMapMethod(Map.prototype.clear), + }), + replacePropertyForTest(Map.prototype, "delete", { + value: poisonMapMethod(Map.prototype.delete), + }), + replacePropertyForTest(Map.prototype, "forEach", { + value: poisonMapMethod(Map.prototype.forEach), + }), + replacePropertyForTest(Map.prototype, "get", { + value: poisonMapMethod(Map.prototype.get), + }), + replacePropertyForTest(Map.prototype, "set", { + value: poisonMapMethod(Map.prototype.set), + }), + replacePropertyForTest(Map.prototype, "size", { + get: poisonMapMethod(mapSizeGetter), + }), + replacePropertyForTest(Object, "freeze", { value: poisoned }), + replacePropertyForTest(Object, "getOwnPropertyDescriptor", { + value: poisoned, + }), + replacePropertyForTest(Object, "getPrototypeOf", { value: poisoned }), + replacePropertyForTest(Object, "isFrozen", { value: poisoned }), + replacePropertyForTest(Reflect, "ownKeys", { + value: (value: object): PropertyKey[] => { + if (schemaInputObjects.some((input) => input === value)) { + return TestReflectApply( + TestReflectOwnKeys, + Reflect, + [value], + ) as PropertyKey[]; + } + return poisoned(); + }, + }), + replacePropertyForTest(WeakSet.prototype, "add", { value: poisoned }), + replacePropertyForTest(WeakSet.prototype, "has", { value: poisoned }), + ]; + }; + __setHostedConfigEvaluatorForTests(async () => { + installPoisoning(); + return snapshot; + }); + + let config: Awaited> | undefined; + let flightState: + | ReturnType + | undefined; + await runWithRequestContext( + { + projectSlug: "primordial-project", + projectId: "primordial-project", + token: "token", + branch: sourceContext.branch, + }, + async () => { + const weakMapGet = WeakMap.prototype.get; + const weakMapSet = WeakMap.prototype.set; + const restoreIdentityPrimordials = [ + replacePropertyForTest(WeakMap.prototype, "get", { + value: function ( + this: WeakMap, + key: object, + ): unknown { + if (key === adapter.fs) return poisoned(); + return TestReflectApply(weakMapGet, this, [key]); + }, + }), + replacePropertyForTest(WeakMap.prototype, "set", { + value: function ( + this: WeakMap, + key: object, + value: unknown, + ): WeakMap { + if (key === adapter.fs) return poisoned(); + return TestReflectApply( + weakMapSet, + this, + [key, value], + ) as unknown as WeakMap; + }, + }), + ]; + try { + config = await getHostedConfig("/hosted-primordial", adapter, { + cacheKey: "primordial-project", + sourceContext, + preparedContext, + }); + flightState = __getHostedConfigFlightStateForTests(); + } finally { + for (let index = restore.length - 1; index >= 0; index -= 1) { + restore[index]!(); + } + for ( + let index = restoreIdentityPrimordials.length - 1; + index >= 0; + index -= 1 + ) { + restoreIdentityPrimordials[index]!(); + } + } + }, + ); + + assertEquals(poisonCalls, 0); + assertEquals(config?.title, "captured"); + assert(Object.isFrozen(config)); + assert(Object.isFrozen(config?.theme)); + assert(Object.isFrozen(config?.theme?.colors)); + assertEquals(flightState, { flights: 0, waiters: 0 }); + }); + + it("selects hosted config from one read without an exists/read race", async () => { + const adapter = setup(); + const source = 'export default { title: "read-once" };'; + adapter.fs.files.set("/veryfront.config.ts", source); + const originalReadFile = adapter.fs.readFile.bind(adapter.fs); + const reads: string[] = []; + let existsCalls = 0; + adapter.fs.readFile = async (path: string) => { + reads.push(path); + return await originalReadFile(path); + }; + adapter.fs.exists = async () => { + existsCalls += 1; + throw new Error("hosted config must not probe exists"); + }; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + }); + let evaluatedSource: string | undefined; + __setHostedConfigEvaluatorForTests(async (payload) => { + evaluatedSource = payload.evaluationOptions.source; + return { title: "evaluated-read-once" }; + }); + const sourceContext = { + productionMode: false, + branch: "feature/read-once", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + + const config = await runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted-read-once", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ); + + assertEquals(config.title, "evaluated-read-once"); + assertEquals(evaluatedSource, source); + assertEquals(reads, [ + "/veryfront.config.js", + "/veryfront.config.ts", + ]); + assertEquals(existsCalls, 0); + }); + + it("propagates hosted config backend failures without trying another candidate", async () => { + const adapter = setup(); + let reads = 0; + let existsCalls = 0; + let evaluations = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + existsCalls += 1; + return false; + }, + readFile: async () => { + reads += 1; + throw new Error("remote config backend unavailable"); + }, + }); + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + return { title: "must-not-evaluate" }; + }); + const sourceContext = { + productionMode: false, + branch: "feature/backend-failure", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted-backend-failure", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(error.slug, "config-parse-error"); + assertEquals(reads, 1); + assertEquals(existsCalls, 0); + assertEquals(evaluations, 0); + }); + + it("does not expose host environment values to hosted config", async () => { + const adapter = setup(); + const envKey = "VERYFRONT_HOSTED_CONFIG_HOST_SECRET_TEST"; + const previousValue = getHostEnv(envKey); + const sourceContext = { + productionMode: false, + branch: "feature/no-host-env", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + setEnv(envKey, "host-secret"); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return ` + import { getEnv } from "veryfront"; + export default { title: getEnv(${JSON.stringify(envKey)}) ?? "missing" }; + `; + }, + }); + + try { + const config = await runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/host-env-isolation", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ); + + assertEquals(config.title, "missing"); + } finally { + if (previousValue === undefined) deleteEnv(envKey); + else setEnv(envKey, previousValue); + } + }); + + it("preserves typed hosted rejection as the parse error cause without host execution", async () => { + const adapter = setup(); + const marker = "__veryfrontHostedConfigHostMutation"; + const host = globalThis as Record; + const previousMarker = Object.getOwnPropertyDescriptor(host, marker); + const sourceContext = { + productionMode: false, + branch: "feature/hostile-config", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + Object.defineProperty(host, marker, { + configurable: true, + value: 0, + writable: true, + }); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return `const hidden = false || eval("globalThis.${marker} = 1"); export default {};`; + }, + }); + + try { + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hostile-hosted-config", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + assertEquals(error.slug, "config-parse-error"); + assert(error.cause instanceof DeclarativeConfigEvaluationError); + const cause = error.cause as DeclarativeConfigEvaluationError; + assertEquals(cause.code, "forbidden-capability"); + assertEquals(cause.phase, "validate"); + assertEquals(cause.reason, "host-global"); + assertEquals(cause.retryable, false); + assertEquals(cause.location?.fileName, "veryfront.config.ts"); + assertEquals(host[marker], 0); + } finally { + if (previousMarker) Object.defineProperty(host, marker, previousMarker); + else delete host[marker]; + } + }); + + it("rejects hosted cache capabilities and capacity before credential lookup or construction", async () => { + const adapter = setup(); + const sourceContext = { + productionMode: false, + branch: "feature/hosted-cache-policy", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + let hostedSource = ""; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return hostedSource; + }, + }); + + const originalEnvGet = Deno.env.get.bind(Deno.env); + let redisCredentialReads = 0; + let downstreamConstructionAttempts = 0; + const restoreEnvGet = replacePropertyForTest(Deno.env, "get", { + value: (key: string): string | undefined => { + if (key === "REDIS_PASSWORD" || key === "REDIS_USERNAME") { + redisCredentialReads += 1; + throw new Error("Hosted config must not read host Redis credentials"); + } + return originalEnvGet(key); + }, + }); + + try { + const loadThenConstruct = async (projectId: string) => { + const config = await runWithRequestContext( + { + projectSlug: projectId, + projectId, + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig(`/hosted-cache-policy/${projectId}`, adapter, { + cacheKey: projectId, + sourceContext, + preparedContext, + }), + ); + downstreamConstructionAttempts += 1; + return config; + }; + + for ( + const [projectId, source, reason] of [ + [ + "hosted-cache-distributed", + `export default { + cache: { + render: { + type: "distributed", + }, + }, + };`, + "hosted-render-cache-backend", + ], + [ + "hosted-cache-capacity", + `export default { + cache: { + render: { + maxEntries: ${MAX_HOSTED_RENDER_CACHE_ENTRIES + 1}, + }, + }, + };`, + "hosted-render-cache-capacity", + ], + [ + "hosted-cache-bundle", + `export default { + cache: { + bundleManifest: { type: "distributed" }, + }, + };`, + "hosted-bundle-manifest-backend", + ], + [ + "hosted-cache-future", + `export default { + cache: { + futurePersistentCache: { path: ".tenant-cache" }, + }, + };`, + "hosted-cache-option", + ], + ] as const + ) { + hostedSource = source; + const error = await assertRejects( + () => loadThenConstruct(projectId), + VeryfrontError, + ) as VeryfrontError; + assertEquals(error.slug, "config-parse-error"); + assert(error.cause instanceof DeclarativeConfigEvaluationError); + const cause = error.cause as DeclarativeConfigEvaluationError; + assertEquals(cause.code, "unsupported-hosted-feature"); + assertEquals(cause.phase, "result"); + assertEquals(cause.reason, reason); + assertEquals(cause.retryable, false); + assertEquals(cause.location?.fileName, "veryfront.config.ts"); + } + assertEquals(redisCredentialReads, 0); + assertEquals(downstreamConstructionAttempts, 0); + } finally { + restoreEnvGet(); + } + }); + + it("never host-executes hosted JavaScript or MJS config variants", async () => { + const marker = "__veryfrontHostedConfigVariantMutation"; + const host = globalThis as Record; + const previousMarker = Object.getOwnPropertyDescriptor(host, marker); + Object.defineProperty(host, marker, { + configurable: true, + value: 0, + writable: true, + }); + + try { + for ( + const [index, configFile] of [ + "veryfront.config.js", + "veryfront.config.mjs", + ].entries() + ) { + const adapter = setup(); + const projectId = `hosted-variant-${index}`; + const branch = `feature/hosted-variant-${index}`; + const sourceContext = { productionMode: false, branch } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === `/${configFile}`, + readFile: async (path: string) => { + if (path !== `/${configFile}`) throw configCandidateNotFound(path); + return `const hidden = false || eval("globalThis.${marker} = 1"); export default {};`; + }, + }); + + const error = await assertRejects( + () => + runWithRequestContext( + { projectSlug: "demo", projectId, token: "token", branch }, + () => + getHostedConfig("/hosted-config-variant", adapter, { + cacheKey: projectId, + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(error.slug, "config-parse-error"); + assert(error.cause instanceof DeclarativeConfigEvaluationError); + assertEquals( + (error.cause as DeclarativeConfigEvaluationError).reason, + "host-global", + ); + assertEquals( + (error.cause as DeclarativeConfigEvaluationError).location?.fileName, + configFile, + ); + assertEquals(host[marker], 0); + } + } finally { + if (previousMarker) Object.defineProperty(host, marker, previousMarker); + else delete host[marker]; + } + }); + + it("rejects hosted multi-project getConfig without context before filesystem I/O", async () => { + const adapter = setup(); + let existsCalls = 0; + let readCalls = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + existsCalls += 1; + return true; + }, + readFile: async () => { + readCalls += 1; + return 'export default { title: "must not load" };'; + }, + }); + + await assertRejects(() => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: "feature/missing-hosted-context", + }, + () => + getConfig("/missing-hosted-context", adapter, { + cacheKey: "project-1", + sourceContext: { + productionMode: false, + branch: "feature/missing-hosted-context", + }, + }), + ) + ); + assertEquals(existsCalls, 0); + assertEquals(readCalls, 0); + }); + + it("rejects mismatched hosted project identity before filesystem I/O", async () => { + const adapter = setup(); + let filesystemCalls = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + filesystemCalls += 1; + return false; + }, + }); + const sourceContext = { + productionMode: false, + branch: "feature/project-identity", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "actual-project", + projectId: "project-actual", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted-project-identity", adapter, { + cacheKey: "project-forged", + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(error.slug, "cache-invariant-violation"); + assertEquals(filesystemCalls, 0); + }); + + it("rejects hosted source and environment identity splicing before filesystem I/O", async () => { + const adapter = setup(); + let filesystemCalls = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + filesystemCalls += 1; + return false; + }, + }); + const sourceContext = { + productionMode: true, + environmentName: "Staging", + releaseId: "release-staging", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: {}, + }); + + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + productionMode: true, + environmentName: sourceContext.environmentName, + releaseId: sourceContext.releaseId, + }, + () => + getHostedConfig("/hosted-environment-identity", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + + assertEquals(error.slug, "cache-invariant-violation"); + assertEquals(filesystemCalls, 0); + }); + + it("allows an exact release only with the empty release evaluator context", async () => { + const adapter = setup(); + let reads = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + reads += 1; + return 'export default { title: "source" };'; + }, + }); + __setHostedConfigEvaluatorForTests(async () => ({ title: "release-config" })); + const sourceContext = { + productionMode: true, + releaseId: "release-exact", + environmentName: null, + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "release", + environment: {}, + }); + + const config = await runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + productionMode: true, + releaseId: sourceContext.releaseId, + }, + () => + getHostedConfig("/hosted-exact-release", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ); + + assertEquals(config.title, "release-config"); + assertEquals(reads, 1); + }); + + it("rejects exact releases with an environment label or tenant secrets", async () => { + const adapter = setup(); + let filesystemCalls = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + filesystemCalls += 1; + return false; + }, + readFile: async () => { + filesystemCalls += 1; + return 'export default { title: "must-not-read" };'; + }, + }); + const sourceContext = { + productionMode: true, + releaseId: "release-exact", + environmentName: null, + } as const; + + for ( + const preparedContext of [ + await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: {}, + }), + await prepareDeclarativeConfigContext({ + environmentName: "release", + environment: { SECRET: "must-not-bind" }, + }), + ] + ) { + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + productionMode: true, + releaseId: sourceContext.releaseId, + }, + () => + getHostedConfig("/hosted-exact-release", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ), + VeryfrontError, + ) as VeryfrontError; + assertEquals(error.slug, "cache-invariant-violation"); + } + + assertEquals(filesystemCalls, 0); + }); + + it("honors hosted cancellation before filesystem I/O", async () => { + const adapter = setup(); + let filesystemCalls = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async () => { + filesystemCalls += 1; + return false; + }, + }); + const sourceContext = { + productionMode: false, + branch: "feature/aborted-hosted-config", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + const controller = new AbortController(); + controller.abort(); + + const error = await assertRejects( + () => + runWithRequestContext( + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/aborted-hosted-config", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + signal: controller.signal, + }), + ), + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + + assertEquals(error.reason, "worker-aborted"); + assertEquals(filesystemCalls, 0); + }); + + it("keys hosted production cache by equivalent context and source digest", async () => { + const adapter = setup(); + const sourceContext = { + productionMode: true, + releaseId: "release-1", + environmentName: "Production", + } as const; + const requestContext = { + projectSlug: "demo", + projectId: "project-1", + token: "token", + productionMode: true, + releaseId: sourceContext.releaseId, + environmentName: sourceContext.environmentName, + } as const; + let sourceRevision = "base"; + let reads = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + reads += 1; + return ` + import { getEnv } from "veryfront"; + export default { + title: ${JSON.stringify(sourceRevision)} + ":" + (getEnv("TENANT") ?? "missing"), + }; + `; + }, + }); + const firstContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-a" }, + }); + const equivalentContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-a" }, + }); + const changedContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-b" }, + }); + const load = (preparedContext: typeof firstContext) => + runWithRequestContext( + requestContext, + () => + getHostedConfig("/production-hosted-config", adapter, { + cacheKey: "project-1", + sourceContext, + preparedContext, + }), + ); + + const first = await load(firstContext); + const equivalent = await load(equivalentContext); + const changedEnvironment = await load(changedContext); + sourceRevision = "changed-source"; + const changedSource = await load(changedContext); + + assertEquals(first.title, "base:tenant-a"); + assert( + first === equivalent, + "equivalent prepared contexts and source must reuse the cached merged config", + ); + assertEquals(changedEnvironment.title, "base:tenant-b"); + assert( + changedEnvironment !== equivalent, + "changed prepared context must not alias an earlier hosted config", + ); + assertEquals(changedSource.title, "changed-source:tenant-b"); + assert( + changedSource !== changedEnvironment, + "changed source digest must not alias an earlier hosted config", + ); + assertEquals(reads, 4); + }); + + it("frames hosted source and environment identities without inherited toJSON hooks", async () => { + const adapter = setup(); + const sourceContext = { + productionMode: true, + releaseId: "release-framed-identity", + environmentName: "Production", + } as const; + const requestContext = { + projectSlug: "framed-identity", + projectId: "framed-identity", + token: "token", + productionMode: true, + releaseId: sourceContext.releaseId, + environmentName: sourceContext.environmentName, + } as const; + let source = "source-a"; + let reads = 0; + let evaluations = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + reads += 1; + return source; + }, + }); + const tenantA = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-a" }, + }); + const tenantB = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-b" }, + }); + __setHostedConfigEvaluatorForTests(async (payload) => { + evaluations += 1; + const environment = payload.evaluationOptions.environment as Record; + return { + title: `${environment.TENANT}:${payload.evaluationOptions.source}`, + }; + }); + + let inheritedIdentityHookCalls = 0; + const restore = [ + definePropertyForTest(Array.prototype, "toJSON", { + value: () => { + inheritedIdentityHookCalls += 1; + throw new Error("inherited Array toJSON must not participate in config identity"); + }, + writable: true, + }), + definePropertyForTest(Object.prototype, "toJSON", { + value: function (this: unknown): string { + // Logger serialization deliberately snapshots general toJSON + // values. Count only the array shape used by the legacy cache + // identity serializer. + if (Array.isArray(this)) { + inheritedIdentityHookCalls += 1; + throw new Error( + "inherited Object toJSON must not participate in config identity", + ); + } + return "non-identity-test-value"; + }, + writable: true, + }), + ]; + const load = (preparedContext: typeof tenantA) => + runWithRequestContext( + requestContext, + () => + getHostedConfig("/framed-hosted-identity", adapter, { + cacheKey: requestContext.projectId, + sourceContext, + preparedContext, + }), + ); + + let first: Awaited> | undefined; + let repeated: Awaited> | undefined; + let changedEnvironment: Awaited> | undefined; + let changedSource: Awaited> | undefined; + try { + first = await load(tenantA); + repeated = await load(tenantA); + changedEnvironment = await load(tenantB); + source = "source-b"; + changedSource = await load(tenantB); + } finally { + for (let index = restore.length - 1; index >= 0; index -= 1) { + restore[index]!(); + } + } + + assertEquals(inheritedIdentityHookCalls, 0); + assertEquals(first?.title, "tenant-a:source-a"); + assert(first === repeated, "equivalent source and environment identities must reuse cache"); + assertEquals(changedEnvironment?.title, "tenant-b:source-a"); + assert( + changedEnvironment !== repeated, + "different tenant environment fingerprints must not share config cache entries", + ); + assertEquals(changedSource?.title, "tenant-b:source-b"); + assert( + changedSource !== changedEnvironment, + "different source digests must not share config cache entries", + ); + assertEquals(evaluations, 3); + assertEquals(reads, 4); + }); + + describe("hosted config single-flight", () => { + const productionSourceContext = { + productionMode: true, + releaseId: "release-single-flight", + environmentName: "Production", + } as const; + type PreparedContext = Awaited< + ReturnType + >; + type TestAdapter = ReturnType; + + function createHostedAdapter(): TestAdapter { + const adapter = setup(); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => true, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return 'export default { title: "source" };'; + }, + }); + return adapter; + } - const transpiled = await transpileConfigSourceForImport(source, "/app/veryfront.config.ts"); - const rewritten = rewriteBareVeryfrontConfigImports(transpiled); - const module = await import(`data:application/javascript;base64,${btoa(rewritten)}`) as { - default: { title: string }; - }; + function loadProductionHostedConfig( + adapter: TestAdapter, + preparedContext: PreparedContext, + options: Readonly<{ + projectId?: string; + signal?: AbortSignal; + }> = {}, + ) { + const projectId = options.projectId ?? "project-single-flight"; + return runWithRequestContext( + { + projectSlug: projectId, + projectId, + token: "token", + productionMode: true, + releaseId: productionSourceContext.releaseId, + environmentName: productionSourceContext.environmentName, + }, + () => + getHostedConfig(`/hosted/${projectId}`, adapter, { + cacheKey: projectId, + sourceContext: productionSourceContext, + preparedContext, + signal: options.signal, + }), + ); + } - assert(module.default.title.startsWith("env:"), "factory must receive an env name"); - }); - }); + function prepareProductionContext(): Promise { + return prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant" }, + }); + } - describe("clearConfigCache", () => { - it("should not throw when called on empty cache", () => { - clearConfigCache(); - }); + it("settles an aborted caller while its admitted filesystem read remains blocked", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const readStarted = Promise.withResolvers(); + const releaseRead = Promise.withResolvers(); + const controller = new AbortController(); + let evaluations = 0; + adapter.fs.readFile = async (path: string) => { + if (path === "/veryfront.config.js") { + readStarted.resolve(); + await releaseRead.promise; + throw configCandidateNotFound(path); + } + if (path === "/veryfront.config.ts") { + return 'export default { title: "source" };'; + } + throw configCandidateNotFound(path); + }; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + return { title: "must-not-evaluate" }; + }); + + const request = loadProductionHostedConfig(adapter, preparedContext, { + signal: controller.signal, + }); + try { + await readStarted.promise; + await waitForHostedSourceReadState({ + active: 1, + queued: 0, + flights: 1, + waiters: 1, + }); + + const failure = assertRejects( + () => request, + DeclarativeConfigEvaluationError, + ) as Promise; + controller.abort(); + const error = await failure; + assertEquals(error.reason, "worker-aborted"); + await waitForHostedSourceReadState({ + active: 1, + queued: 0, + flights: 1, + waiters: 0, + }); + assertEquals(evaluations, 0); + } finally { + releaseRead.resolve(); + await Promise.allSettled([request]); + } + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); - it("should invalidate previously cached configs", async () => { - const adapter = setup(); + it("coalesces one immutable production read before distinct environment evaluations", async () => { + const adapter = createHostedAdapter(); + const firstContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-a" }, + }); + const secondContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "tenant-b" }, + }); + const readStarted = Promise.withResolvers(); + const releaseRead = Promise.withResolvers(); + let reads = 0; + let evaluations = 0; + adapter.fs.readFile = async (path: string) => { + if (path !== "/veryfront.config.js") throw configCandidateNotFound(path); + reads += 1; + readStarted.resolve(); + await releaseRead.promise; + return 'export default { title: "source" };'; + }; + __setHostedConfigEvaluatorForTests(async (payload) => { + evaluations += 1; + const environment = payload.evaluationOptions.environment as Record< + string, + string + >; + return { title: environment.TENANT ?? "missing" }; + }); + + const first = loadProductionHostedConfig(adapter, firstContext); + let second: ReturnType | undefined; + try { + await readStarted.promise; + second = loadProductionHostedConfig(adapter, secondContext); + await waitForHostedSourceReadState({ + active: 1, + queued: 0, + flights: 1, + waiters: 2, + }); + assertEquals(reads, 1); + releaseRead.resolve(); + + const [firstConfig, secondConfig] = await Promise.all([first, second]); + assertEquals(firstConfig.title, "tenant-a"); + assertEquals(secondConfig.title, "tenant-b"); + assert(firstConfig !== secondConfig); + assertEquals(evaluations, 2); + assertEquals(reads, 1); + } finally { + releaseRead.resolve(); + await Promise.allSettled( + [first, second].filter( + (request): request is ReturnType => + request !== undefined, + ), + ); + } + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); - const config1 = await getConfig("/test-project", adapter); - assert(config1 !== null); + it("keeps concurrent preview reads distinct so each can observe its source snapshot", async () => { + const adapter = createHostedAdapter(); + const sourceContext = { + productionMode: false, + branch: "feature/mutable-concurrent-source", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + const readStarted = [ + Promise.withResolvers(), + Promise.withResolvers(), + ] as const; + const releaseRead = [ + Promise.withResolvers(), + Promise.withResolvers(), + ] as const; + let revision = "first-source"; + let reads = 0; + adapter.fs.readFile = async (path: string) => { + if (path !== "/veryfront.config.js") throw configCandidateNotFound(path); + const index = reads; + const source = revision; + reads += 1; + readStarted[index]?.resolve(); + await releaseRead[index]!.promise; + return source; + }; + __setHostedConfigEvaluatorForTests(async (payload) => ({ + title: payload.evaluationOptions.source, + })); + const load = () => + runWithRequestContext( + { + projectSlug: "mutable-concurrent-project", + projectId: "mutable-concurrent-project", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted/mutable-concurrent-project", adapter, { + cacheKey: "mutable-concurrent-project", + sourceContext, + preparedContext, + }), + ); + + const first = load(); + let second: ReturnType | undefined; + try { + await readStarted[0].promise; + revision = "second-source"; + second = load(); + await readStarted[1].promise; + await waitForHostedSourceReadState({ + active: 2, + queued: 0, + flights: 2, + waiters: 2, + }); + const admission = __getHostedConfigSourceReadStateForTests(); + assert(admission.active <= admission.maxActive); + assertEquals(reads, 2); + + releaseRead[1].resolve(); + const secondConfig = await second; + releaseRead[0].resolve(); + const firstConfig = await first; + assertEquals(firstConfig.title, "first-source"); + assertEquals(secondConfig.title, "second-source"); + } finally { + for (const release of releaseRead) release.resolve(); + await Promise.allSettled( + [first, second].filter( + (request): request is ReturnType => request !== undefined, + ), + ); + } + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); - const config2 = await getConfig("/test-project", adapter); - assertEquals(config2, config1); + it("bounds source reads and retains orphaned active reads until capacity really recovers", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const releaseReads = Promise.withResolvers(); + const admission = __getHostedConfigSourceReadStateForTests(); + const uniqueFlightCount = admission.maxActive + admission.maxQueued; + const uniqueProjectIds = Array.from( + { length: uniqueFlightCount }, + (_, index) => `source-read-bounded-${index}`, + ); + const projectIds = [uniqueProjectIds[0]!, ...uniqueProjectIds]; + const controllers = projectIds.map(() => new AbortController()); + let reads = 0; + const readProjectIds: Array = []; + adapter.fs.readFile = async (path: string) => { + if (path !== "/veryfront.config.js") throw configCandidateNotFound(path); + reads += 1; + readProjectIds.push(getCurrentRequestContext()?.projectId); + await releaseReads.promise; + return 'export default { title: "source" };'; + }; + __setHostedConfigEvaluatorForTests(async () => ({ + title: "capacity-recovered", + })); + + const pending = projectIds.map((projectId, index) => + loadProductionHostedConfig(adapter, preparedContext, { + projectId, + signal: controllers[index]!.signal, + }) + ); + let recovered: + | ReturnType + | undefined; + try { + await waitForHostedSourceReadState({ + active: admission.maxActive, + queued: admission.maxQueued, + flights: uniqueFlightCount, + waiters: uniqueFlightCount + 1, + }); + assertEquals(reads, admission.maxActive); + + const overflow = await assertRejects( + () => + loadProductionHostedConfig(adapter, preparedContext, { + projectId: "source-read-bounded-overflow", + }), + VeryfrontError, + ) as VeryfrontError; + assertEquals(overflow.slug, "service-overloaded"); + assert(overflow.cause instanceof DeclarativeConfigEvaluationError); + assertEquals( + (overflow.cause as DeclarativeConfigEvaluationError).reason, + "worker-overloaded", + ); + + const failures = pending.map((request) => + assertRejects(() => request, DeclarativeConfigEvaluationError) + ); + for (const controller of controllers) controller.abort(); + await Promise.all(failures); + await waitForHostedSourceReadState({ + active: admission.maxActive, + queued: 0, + flights: admission.maxActive, + waiters: 0, + }); + assertEquals(reads, admission.maxActive); + + recovered = loadProductionHostedConfig(adapter, preparedContext, { + projectId: "source-read-bounded-recovered", + }); + await waitForHostedSourceReadState({ + active: admission.maxActive, + queued: 1, + flights: admission.maxActive + 1, + waiters: 1, + }); + assertEquals(reads, admission.maxActive); + + releaseReads.resolve(); + const config = await recovered; + assertEquals(config.title, "capacity-recovered"); + assertEquals(reads, admission.maxActive + 1); + assertEquals( + readProjectIds[readProjectIds.length - 1], + "source-read-bounded-recovered", + ); + } finally { + for (const controller of controllers) controller.abort(); + releaseReads.resolve(); + await Promise.allSettled([ + ...pending, + ...(recovered ? [recovered] : []), + ]); + } + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); - clearConfigCache(); - const config3 = await getConfig("/test-project", adapter); - assert(config3 !== null); - assert(config3 !== config1, "Expected new object after cache clear"); - }); - }); + it("uses captured abort and TextDecoder primordials for Uint8Array hosted sources", async () => { + const adapter = createHostedAdapter(); + const firstContext = await prepareProductionContext(); + const secondContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: { TENANT: "poison-reset" }, + }); + const sourceBytes = new TextEncoder().encode( + 'export default { title: "byte-source" };', + ); + const callerController = new AbortController(); + const callerSignal = callerController.signal; + const secondEvaluationStarted = Promise.withResolvers(); + const releaseSecondEvaluation = Promise.withResolvers(); + let secondEvaluationSignal: AbortSignal | undefined; + let evaluations = 0; + Object.assign(adapter.fs, { + readFile: async (path: string) => { + if (path !== "/veryfront.config.js") throw configCandidateNotFound(path); + return sourceBytes; + }, + }); + __setHostedConfigEvaluatorForTests(async (_payload, options) => { + evaluations += 1; + if (evaluations === 1) return { title: "decoded-byte-source" }; + secondEvaluationSignal = options?.signal; + secondEvaluationStarted.resolve(); + await releaseSecondEvaluation.promise; + return { title: "must-be-aborted-by-reset" }; + }); + + const abortSignalAborted = TestObjectGetOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", + )?.get; + if (!abortSignalAborted) throw new Error("Expected AbortSignal aborted getter"); + const abortSignalPrototype = AbortSignal.prototype; + const textDecoderPrototype = TextDecoder.prototype; + let poisonCalls = 0; + const descriptorPoisonCalls: string[] = []; + const poison = (): never => { + poisonCalls += 1; + throw new Error("ambient loader lifecycle primordial must not run"); + }; + const restore: Array<() => void> = []; + let secondRequest: + | ReturnType + | undefined; + try { + const replace = ( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, + ): void => { + restore.push(replacePropertyForTest(target, key, descriptor)); + }; + replace(globalThis, "TextDecoder", { value: poison }); + replace(textDecoderPrototype, "decode", { value: poison }); + replace(AbortController.prototype, "signal", { get: poison }); + replace(AbortController.prototype, "abort", { value: poison }); + replace(AbortSignal.prototype, "aborted", { get: poison }); + replace(EventTarget.prototype, "addEventListener", { value: poison }); + replace(EventTarget.prototype, "removeEventListener", { + value: poison, + }); + for ( + const descriptorField of [ + "value", + "writable", + "get", + "enumerable", + "configurable", + ] as const + ) { + const descriptorPoison = function (this: unknown): unknown { + if (typeof this !== "object" || this === null) return undefined; + const value = TestObjectGetOwnPropertyDescriptor(this, "value") + ?.value; + const getter = TestObjectGetOwnPropertyDescriptor(this, "get") + ?.value; + const isLoaderSignalDataDescriptor = typeof value === "object" && + value !== null && + TestReflectApply( + TestObjectGetPrototypeOf, + Object, + [value], + ) === abortSignalPrototype; + const getterName = typeof getter === "function" + ? TestObjectGetOwnPropertyDescriptor(getter, "name")?.value + : undefined; + if ( + isLoaderSignalDataDescriptor || + getterName === "intrinsicSignalAbortedOwnGetter" + ) { + descriptorPoisonCalls.push(descriptorField); + return poison(); + } + return undefined; + }; + restore.push( + defineNullPrototypeAccessorForTest( + Object.prototype, + descriptorField, + descriptorPoison, + descriptorPoison, + ), + ); + } + + const first = await loadProductionHostedConfig(adapter, firstContext, { + projectId: "captured-byte-source", + signal: callerSignal, + }); + assertEquals(first.title, "decoded-byte-source"); + + secondRequest = loadProductionHostedConfig(adapter, secondContext, { + projectId: "captured-byte-source-reset", + signal: callerSignal, + }); + await secondEvaluationStarted.promise; + __setHostedConfigEvaluatorForTests(); + assert(secondEvaluationSignal); + assertEquals( + TestReflectApply(abortSignalAborted, secondEvaluationSignal, []), + true, + ); + releaseSecondEvaluation.resolve(); + const error = await assertRejects( + () => secondRequest!, + DeclarativeConfigEvaluationError, + ) as DeclarativeConfigEvaluationError; + assertEquals(error.reason, "worker-aborted"); + assertEquals( + poisonCalls, + 0, + `Descriptor poison calls: ${descriptorPoisonCalls.join(", ")}`, + ); + } finally { + releaseSecondEvaluation.resolve(); + await Promise.allSettled( + secondRequest ? [secondRequest] : [], + ); + for (let index = restore.length - 1; index >= 0; index -= 1) { + restore[index]!(); + } + __setHostedConfigEvaluatorForTests(); + } + assertEquals(poisonCalls, 0); + await waitForHostedSourceReadState({ + active: 0, + queued: 0, + flights: 0, + waiters: 0, + }); + }); - describe("getCachedConfigSync", () => { - it("should return null for uncached project", () => { - clearConfigCache(); - assertEquals(getCachedConfigSync("/nonexistent-project"), null); - }); + it("keeps WebIDL conversion and source-read FIFO independent of poisoned prototypes", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const sourceBytes = new TextEncoder().encode( + 'export default { title: "poison-safe-source" };', + ); + const firstReadStarted = Promise.withResolvers(); + const secondReadStarted = Promise.withResolvers(); + const thirdReadStarted = Promise.withResolvers(); + const fourthReadStarted = Promise.withResolvers(); + const releaseFirstRead = Promise.withResolvers(); + const releaseSecondRead = Promise.withResolvers(); + const releaseThirdRead = Promise.withResolvers(); + const releaseFourthRead = Promise.withResolvers(); + const firstEvaluationStarted = Promise.withResolvers(); + const releaseFirstEvaluation = Promise.withResolvers(); + let fourthStarted = false; + Object.assign(adapter.fs, { + readFile: async (path: string) => { + if (path !== "/veryfront.config.js") throw configCandidateNotFound(path); + switch (getCurrentRequestContext()?.projectId) { + case "prototype-poison-first": + firstReadStarted.resolve(); + await releaseFirstRead.promise; + break; + case "prototype-poison-second": + secondReadStarted.resolve(); + await releaseSecondRead.promise; + break; + case "prototype-poison-third": + thirdReadStarted.resolve(); + await releaseThirdRead.promise; + break; + case "prototype-poison-fourth": + fourthStarted = true; + fourthReadStarted.resolve(); + await releaseFourthRead.promise; + break; + default: + throw new Error("Unexpected source-read request context"); + } + return sourceBytes; + }, + }); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + if (evaluations === 1) { + firstEvaluationStarted.resolve(); + await releaseFirstEvaluation.promise; + } + return { title: "poison-safe-source" }; + }); + + let poisonCalls = 0; + const poison = (): never => { + poisonCalls += 1; + throw new Error("inherited WebIDL or array-index hook must not run"); + }; + const inheritedArrayIndexGetter = (): undefined => undefined; + const inheritedArrayIndexSetter = function ( + this: unknown, + value: unknown, + ): void { + const queuedState = typeof value === "object" && value !== null + ? TestObjectGetOwnPropertyDescriptor(value, "state")?.value + : undefined; + const waiterCount = typeof value === "object" && value !== null + ? TestObjectGetOwnPropertyDescriptor(value, "waiterCount")?.value + : undefined; + if (queuedState === "queued" && typeof waiterCount === "number") { + poison(); + } + if ((typeof this !== "object" && typeof this !== "function") || this === null) { + poison(); + } + const descriptor = TestReflectApply( + TestObjectCreate, + Object, + [null], + ) as PropertyDescriptor; + descriptor.value = value; + descriptor.writable = true; + descriptor.enumerable = true; + descriptor.configurable = true; + TestReflectApply(TestObjectDefineProperty, Object, [ + this, + "0", + descriptor, + ]); + }; + let restoreCapture: (() => void) | undefined; + let restoreIgnoreBOM: (() => void) | undefined; + let restoreArrayIndex: (() => void) | undefined; + let first: + | ReturnType + | undefined; + let second: + | ReturnType + | undefined; + let third: + | ReturnType + | undefined; + let fourth: + | ReturnType + | undefined; + let firstConfig: + | Awaited> + | undefined; + let secondConfig: + | Awaited> + | undefined; + let thirdConfig: + | Awaited> + | undefined; + let fourthConfig: + | Awaited> + | undefined; + let queuedState: + | ReturnType + | undefined; + let fifoState: + | ReturnType + | undefined; + let finalState: + | ReturnType + | undefined; + let fourthStartedBeforeItsTurn = false; + try { + restoreCapture = defineNullPrototypeAccessorForTest( + Object.prototype, + "capture", + poison, + poison, + ); + restoreIgnoreBOM = defineNullPrototypeAccessorForTest( + Object.prototype, + "ignoreBOM", + poison, + poison, + ); + restoreArrayIndex = defineNullPrototypeAccessorForTest( + Array.prototype, + "0", + inheritedArrayIndexGetter, + inheritedArrayIndexSetter, + ); + + first = loadProductionHostedConfig(adapter, preparedContext, { + projectId: "prototype-poison-first", + signal: new AbortController().signal, + }); + await firstReadStarted.promise; + second = loadProductionHostedConfig(adapter, preparedContext, { + projectId: "prototype-poison-second", + signal: new AbortController().signal, + }); + await secondReadStarted.promise; + third = loadProductionHostedConfig(adapter, preparedContext, { + projectId: "prototype-poison-third", + signal: new AbortController().signal, + }); + fourth = loadProductionHostedConfig(adapter, preparedContext, { + projectId: "prototype-poison-fourth", + signal: new AbortController().signal, + }); + + for (let attempt = 0; attempt < 100; attempt += 1) { + queuedState = __getHostedConfigSourceReadStateForTests(); + if ( + queuedState.active === 2 && + queuedState.queued === 2 && + queuedState.flights === 4 && + queuedState.waiters === 4 + ) { + break; + } + await Promise.resolve(); + } + + releaseFirstRead.resolve(); + await thirdReadStarted.promise; + await firstEvaluationStarted.promise; + // The first source selection remains leased while its caller + // evaluates the selected config. It must therefore remain visible + // even though its read slot has already dispatched the third read. + fifoState = __getHostedConfigSourceReadStateForTests(); + fourthStartedBeforeItsTurn = fourthStarted; + + releaseThirdRead.resolve(); + await fourthReadStarted.promise; + releaseFirstEvaluation.resolve(); + releaseSecondRead.resolve(); + releaseFourthRead.resolve(); + firstConfig = await first; + secondConfig = await second; + thirdConfig = await third; + fourthConfig = await fourth; + + for (let attempt = 0; attempt < 100; attempt += 1) { + finalState = __getHostedConfigSourceReadStateForTests(); + if ( + finalState.active === 0 && + finalState.queued === 0 && + finalState.flights === 0 && + finalState.waiters === 0 + ) { + break; + } + await Promise.resolve(); + } + } finally { + // Restore the numeric array hook before aggregate cleanup: Deno's + // assertion and console internals legitimately use ordinary arrays. + restoreArrayIndex?.(); + restoreArrayIndex = undefined; + releaseFirstRead.resolve(); + releaseSecondRead.resolve(); + releaseThirdRead.resolve(); + releaseFourthRead.resolve(); + releaseFirstEvaluation.resolve(); + await Promise.allSettled( + [first, second, third, fourth].filter( + ( + request, + ): request is ReturnType => request !== undefined, + ), + ); + restoreIgnoreBOM?.(); + restoreCapture?.(); + } + + assertEquals(poisonCalls, 0); + assertEquals(queuedState, { + active: 2, + queued: 2, + flights: 4, + waiters: 4, + maxActive: 2, + maxQueued: 16, + }); + assertEquals(fifoState?.active, 2); + assertEquals(fifoState?.queued, 1); + assertEquals(fifoState?.flights, 4); + assertEquals(fifoState?.waiters, 4); + assertEquals(fourthStartedBeforeItsTurn, false); + assertEquals(firstConfig?.title, "poison-safe-source"); + assertEquals(secondConfig?.title, "poison-safe-source"); + assertEquals(thirdConfig?.title, "poison-safe-source"); + assertEquals(fourthConfig?.title, "poison-safe-source"); + assertEquals(finalState, { + active: 0, + queued: 0, + flights: 0, + waiters: 0, + maxActive: 2, + maxQueued: 16, + }); + }); - it("returns the config cached for a project directory", async () => { - const adapter = setup(); - const config = await getConfig("/cached-project", adapter); + it("shields the captured Promise observer from poisoned constructor and species hooks", async () => { + const source = Promise.resolve("promise-observer-safe"); + let poisonCalls = 0; + const poison = (): never => { + poisonCalls += 1; + throw new Error("ambient Promise constructor hook must not run"); + }; + let restoreConstructor: (() => void) | undefined; + let restoreSpecies: (() => void) | undefined; + let observed: Promise | undefined; + let sourceRetainedOwnConstructor = false; + try { + restoreConstructor = definePropertyForTest( + Promise.prototype, + "constructor", + { get: poison }, + ); + restoreSpecies = definePropertyForTest( + Promise, + Symbol.species, + { get: poison }, + ); + + observed = __observePromiseForTests(source); + sourceRetainedOwnConstructor = TestObjectGetOwnPropertyDescriptor( + source, + "constructor", + ) !== undefined; + } finally { + restoreSpecies?.(); + restoreConstructor?.(); + } + + assert(observed); + assertEquals(await observed, "promise-observer-safe"); + assertEquals(poisonCalls, 0); + assertEquals(sourceRetainedOwnConstructor, false); + }); - assertEquals(getCachedConfigSync("/cached-project"), config); - }); + it("shares one exact production evaluation and result across concurrent callers", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const started = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const originalReadFile = adapter.fs.readFile.bind(adapter.fs); + let selectedSourceReads = 0; + adapter.fs.readFile = async (path: string) => { + if (path === "/veryfront.config.ts") selectedSourceReads += 1; + return await originalReadFile(path); + }; + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async (_payload, options) => { + evaluations += 1; + assertEquals(options?.signal?.aborted, false); + started.resolve(); + await resume.promise; + return { title: "coalesced" }; + }); + + const first = loadProductionHostedConfig(adapter, preparedContext); + await started.promise; + const second = loadProductionHostedConfig(adapter, preparedContext); + await waitForHostedFlightState({ flights: 1, waiters: 2 }); + assertEquals(evaluations, 1); + + resume.resolve(); + const [firstConfig, secondConfig] = await Promise.all([first, second]); + assert(firstConfig === secondConfig); + assertEquals(selectedSourceReads, 1); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + + const cached = await loadProductionHostedConfig(adapter, preparedContext); + assert(cached === firstConfig); + assertEquals(evaluations, 1); + assertEquals(selectedSourceReads, 2); + }); - it("should return null after cache is cleared", async () => { - const adapter = setup(); + it("removes rejected flights and never caches their failure", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const firstEvaluationStarted = Promise.withResolvers(); + const rejectFirstEvaluation = Promise.withResolvers(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + if (evaluations === 1) { + firstEvaluationStarted.resolve(); + await rejectFirstEvaluation.promise; + throw new Error("deterministic hosted evaluation failure"); + } + return { title: "recovered" }; + }); + + const first = loadProductionHostedConfig(adapter, preparedContext); + await firstEvaluationStarted.promise; + const second = loadProductionHostedConfig(adapter, preparedContext); + try { + await waitForHostedFlightState({ flights: 1, waiters: 2 }); + rejectFirstEvaluation.resolve(); + + const failures = await Promise.allSettled([first, second]); + assert(failures.every((result) => result.status === "rejected")); + assertEquals(evaluations, 1); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + } finally { + rejectFirstEvaluation.resolve(); + await Promise.allSettled([first, second]); + } + + const recovered = await loadProductionHostedConfig(adapter, preparedContext); + assertEquals(recovered.title, "recovered"); + assertEquals(evaluations, 2); + }); - await getConfig("/cached-project", adapter); - clearConfigCache(); + it("separates flights across cache revisions and blocks stale cache seeding", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const starts = [ + Promise.withResolvers(), + Promise.withResolvers(), + ] as const; + const outcomes = [ + Promise.withResolvers<{ title: string }>(), + Promise.withResolvers<{ title: string }>(), + ] as const; + let evaluations = 0; + __setHostedConfigEvaluatorForTests(() => { + const index = evaluations; + evaluations += 1; + starts[index]?.resolve(); + return outcomes[index]!.promise; + }); + + const staleRequest = loadProductionHostedConfig(adapter, preparedContext); + await starts[0].promise; + clearConfigCache(); + const freshRequest = loadProductionHostedConfig(adapter, preparedContext); + await starts[1].promise; + await waitForHostedFlightState({ flights: 2, waiters: 2 }); + + outcomes[1].resolve({ title: "fresh-revision" }); + const fresh = await freshRequest; + outcomes[0].resolve({ title: "stale-revision" }); + const stale = await staleRequest; + assertEquals(fresh.title, "fresh-revision"); + assertEquals(stale.title, "stale-revision"); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + + const cached = await loadProductionHostedConfig(adapter, preparedContext); + assertEquals(cached.title, "fresh-revision"); + assert(cached === fresh); + assertEquals(evaluations, 2); + }); - assertEquals(getCachedConfigSync("/cached-project"), null); - }); - }); + it("lets one caller abort without cancelling its peers", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const started = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const firstController = new AbortController(); + const secondController = new AbortController(); + let sharedSignal: AbortSignal | undefined; + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async (_payload, options) => { + evaluations += 1; + sharedSignal = options?.signal; + started.resolve(); + await resume.promise; + return { title: "peer-survived" }; + }); + + const first = loadProductionHostedConfig(adapter, preparedContext, { + signal: firstController.signal, + }); + await started.promise; + const second = loadProductionHostedConfig(adapter, preparedContext, { + signal: secondController.signal, + }); + await waitForHostedFlightState({ flights: 1, waiters: 2 }); + + const firstFailure = assertRejects( + () => first, + DeclarativeConfigEvaluationError, + ) as Promise; + firstController.abort(); + const error = await firstFailure; + assertEquals(error.reason, "worker-aborted"); + await waitForHostedFlightState({ flights: 1, waiters: 1 }); + assertEquals(sharedSignal?.aborted, false); + + resume.resolve(); + const survivingPeer = await second; + assertEquals(survivingPeer.title, "peer-survived"); + assertEquals(evaluations, 1); + }); - describe("getConfig", () => { - it("should return default config when no config file exists", async () => { - const adapter = setup(); + it("aborts the shared evaluation only after every waiter leaves", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const started = Promise.withResolvers(); + const sharedOutcome = Promise.withResolvers<{ title: string }>(); + const firstController = new AbortController(); + const secondController = new AbortController(); + let sharedSignal: AbortSignal | undefined; + let evaluations = 0; + __setHostedConfigEvaluatorForTests((_payload, options) => { + evaluations += 1; + if (evaluations > 1) return Promise.resolve({ title: "recovered" }); + sharedSignal = options?.signal; + started.resolve(); + return sharedOutcome.promise; + }); + + const first = loadProductionHostedConfig(adapter, preparedContext, { + signal: firstController.signal, + }); + await started.promise; + const second = loadProductionHostedConfig(adapter, preparedContext, { + signal: secondController.signal, + }); + await waitForHostedFlightState({ flights: 1, waiters: 2 }); + const firstFailure = assertRejects( + () => first, + DeclarativeConfigEvaluationError, + ); + const secondFailure = assertRejects( + () => second, + DeclarativeConfigEvaluationError, + ); - const config = await getConfig("/empty-project", adapter); - assert(config !== null); - assertEquals(config.title, "Veryfront App"); - assertEquals(config.description, "Built with Veryfront"); - assertEquals(config.build?.outDir, "dist"); - assertEquals(config.dev?.port, 3000); - assertEquals(config.dev?.host, "localhost"); - assertEquals(config.dev?.open, false); - assertEquals(config.client?.moduleResolution, "cdn"); - assertEquals(config.client?.cdn?.provider, "esm.sh"); - }); + firstController.abort(); + await firstFailure; + assertEquals(sharedSignal?.aborted, false); + secondController.abort(); + await secondFailure; + assertEquals(sharedSignal?.aborted, true); + await waitForHostedFlightState({ flights: 1, waiters: 0 }); + + const recovered = await loadProductionHostedConfig(adapter, preparedContext); + assertEquals(recovered.title, "recovered"); + assertEquals(evaluations, 2); + sharedOutcome.reject(new Error("cancelled evaluator drained")); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + }); - it("should return cached config on subsequent calls", async () => { - const adapter = setup(); + it("coalesces preview branches without persisting their result", async () => { + const adapter = createHostedAdapter(); + const sourceContext = { + productionMode: false, + branch: "feature/single-flight", + } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); + const firstStarted = Promise.withResolvers(); + const resumeFirst = Promise.withResolvers(); + let evaluations = 0; + __setHostedConfigEvaluatorForTests(async () => { + evaluations += 1; + if (evaluations === 1) { + firstStarted.resolve(); + await resumeFirst.promise; + } + return { title: `preview-evaluation-${evaluations}` }; + }); + const load = () => + runWithRequestContext( + { + projectSlug: "preview-project", + projectId: "preview-project", + token: "token", + branch: sourceContext.branch, + }, + () => + getHostedConfig("/hosted/preview-project", adapter, { + cacheKey: "preview-project", + sourceContext, + preparedContext, + }), + ); + + const first = load(); + await firstStarted.promise; + const second = load(); + await waitForHostedFlightState({ flights: 1, waiters: 2 }); + resumeFirst.resolve(); + const [firstConfig, secondConfig] = await Promise.all([first, second]); + assert(firstConfig === secondConfig); + assertEquals(firstConfig.title, "preview-evaluation-1"); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + + const next = await load(); + assertEquals(next.title, "preview-evaluation-2"); + assert(next !== firstConfig); + assertEquals(evaluations, 2); + }); - const config1 = await getConfig("/cached-test", adapter); - const config2 = await getConfig("/cached-test", adapter); + it("bounds unique flights and releases capacity after cancellation", async () => { + const adapter = createHostedAdapter(); + const preparedContext = await prepareProductionContext(); + const limit = DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxActive + + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxQueued; + const controllers = Array.from( + { length: limit }, + () => new AbortController(), + ); + let releaseImmediately = false; + let evaluations = 0; + __setHostedConfigEvaluatorForTests((_payload, options) => { + evaluations += 1; + if (releaseImmediately) return Promise.resolve({ title: "capacity-recovered" }); + return new Promise((_, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new Error("bounded flight cancelled")), + { once: true }, + ); + }); + }); + + const pending = controllers.map((controller, index) => + loadProductionHostedConfig(adapter, preparedContext, { + projectId: `bounded-project-${index}`, + signal: controller.signal, + }) + ); + await waitForHostedFlightState({ flights: limit, waiters: limit }); + assertEquals(evaluations, limit); - assertEquals(config1, config2); + const overflow = await assertRejects( + () => + loadProductionHostedConfig(adapter, preparedContext, { + projectId: "bounded-project-overflow", + }), + VeryfrontError, + ) as VeryfrontError; + assertEquals(overflow.slug, "service-overloaded"); + assert(overflow.cause instanceof DeclarativeConfigEvaluationError); + assertEquals( + (overflow.cause as DeclarativeConfigEvaluationError).reason, + "worker-overloaded", + ); + assertEquals(evaluations, limit); + + const pendingFailures = pending.map((request) => + assertRejects(() => request, DeclarativeConfigEvaluationError) + ); + for (const controller of controllers) controller.abort(); + await Promise.all(pendingFailures); + await waitForHostedFlightState({ flights: 0, waiters: 0 }); + + releaseImmediately = true; + const recovered = await loadProductionHostedConfig(adapter, preparedContext, { + projectId: "bounded-project-recovered", + }); + assertEquals(recovered.title, "capacity-recovered"); + assertEquals(evaluations, limit + 1); + }); }); - it("should cache separately for different project directories", async () => { + it("preserves executable config for single-project virtual filesystems", async () => { const adapter = setup(); + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async () => ` + const resolveTitle = () => "single-project-executable"; + export default { title: resolveTitle() }; + `, + }); - const configA = await getConfig("/project-a", adapter); - const configB = await getConfig("/project-b", adapter); + const config = await getConfig("/single-project-virtual-config", adapter, { + cacheKey: "single-project", + }); - assert(configA !== null); - assert(configB !== null); - assertEquals(configA.title, "Veryfront App"); - assertEquals(configB.title, "Veryfront App"); + assertEquals(config.title, "single-project-executable"); }); - it("should load and validate a JS config file", async () => { + it("validates an explicit falsy default from a trusted virtual module", async () => { const adapter = setup(); - const projectDir = await Deno.makeTempDir({ prefix: "vf-config-js-" }); - const configPath = `${projectDir}/veryfront.config.js`; - const source = 'export default { title: "JS Project" };'; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + readFile: async () => "export default false;", + }); - try { - await Deno.writeTextFile(configPath, source); - adapter.fs.files.set(configPath, source); + const error = await assertRejects( + () => getConfig("/falsy-single-project-config", adapter), + VeryfrontError, + ) as VeryfrontError; - const config = await getConfig(projectDir, adapter); - assertEquals(config.title, "JS Project"); - } finally { - await Deno.remove(projectDir, { recursive: true }); - } + assertEquals(error.slug, "config-validation-failed"); + assertEquals(error.context, { + field: "", + expected: "Invalid input: expected object, received boolean", + }); }); - it("loads canonical source integration restrictions", async () => { + it("retains named-export-only trusted virtual config modules", async () => { const adapter = setup(); Object.assign(adapter.fs, { getUnderlyingAdapter: () => adapter.fs, isMultiProjectMode: () => false, isVeryfrontAdapter: () => true, + readFile: async () => 'export const title = "named-virtual-config";', }); - const projectDir = "/typed-integration-config"; - const configPath = "/veryfront.config.ts"; - const source = [ - 'import { defineConfig } from "veryfront";', - 'export default defineConfig({ integrations: { allow: { linear: { allowedTools: ["search_issues"] } } } });', - ].join("\n"); - adapter.fs.files.set(configPath, source); + const config = await getConfig("/named-single-project-config", adapter); - const config = await getConfig(projectDir, adapter); - assertEquals(config.integrations, { - allow: { linear: { allowedTools: ["search_issues"] } }, - }); + assertEquals(config.title, "named-virtual-config"); }); it("isolates virtual config values by exact branch, release, and environment", async () => { @@ -242,7 +3236,8 @@ export default config as const; isMultiProjectMode: () => true, isVeryfrontAdapter: () => true, exists: async (path: string) => path === "/veryfront.config.ts", - readFile: async () => { + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); const source = getCurrentRequestContext(); const target = !source?.productionMode ? `branch:${source?.branch ?? "main"}` @@ -254,13 +3249,18 @@ export default config as const; }, }); - const loadFor = ( + const loadFor = async ( source: Parameters[0], - ) => - runWithRequestContext( + ) => { + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: source.environmentName ?? + (source.productionMode ? "release" : "preview"), + environment: {}, + }); + return runWithRequestContext( source, () => - getConfig("/source-qualified-config", adapter, { + getHostedConfig("/source-qualified-config", adapter, { cacheKey: "project-1", sourceContext: { productionMode: source.productionMode ?? false, @@ -268,27 +3268,33 @@ export default config as const; branch: source.branch, environmentName: source.environmentName, }, + preparedContext, }), ); + }; const main = await loadFor({ projectSlug: "demo", + projectId: "project-1", token: "token", branch: "main", }); const preview = await loadFor({ projectSlug: "demo", + projectId: "project-1", token: "token", branch: "feature/integrations", }); const release = await loadFor({ projectSlug: "demo", + projectId: "project-1", token: "token", productionMode: true, releaseId: "release-1", }); const environment = await loadFor({ projectSlug: "demo", + projectId: "project-1", token: "token", productionMode: true, releaseId: "release-1", @@ -300,9 +3306,15 @@ export default config as const; assertEquals(release.title, "release:release-1"); assertEquals(environment.title, "env:Production:release-1"); - await loadFor({ projectSlug: "demo", token: "token", branch: "main" }); await loadFor({ projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: "main", + }); + await loadFor({ + projectSlug: "demo", + projectId: "project-1", token: "token", productionMode: true, releaseId: "release-1", @@ -314,6 +3326,7 @@ export default config as const; "release:release-1", "env:Production:release-1", "branch:main", + "env:Production:release-1", ]); }); @@ -325,17 +3338,30 @@ export default config as const; isMultiProjectMode: () => true, isVeryfrontAdapter: () => true, exists: async (path: string) => path === "/veryfront.config.ts", - readFile: async () => `export default { title: ${JSON.stringify(revision)} };`, + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + return `export default { title: ${JSON.stringify(revision)} };`; + }, }); const sourceContext = { productionMode: false, branch: "feature/integrations" } as const; + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }); const loadBranchConfig = () => runWithRequestContext( - { projectSlug: "demo", token: "token", branch: sourceContext.branch }, + { + projectSlug: "demo", + projectId: "project-1", + token: "token", + branch: sourceContext.branch, + }, () => - getConfig("/mutable-branch-config", adapter, { + getHostedConfig("/mutable-branch-config", adapter, { cacheKey: "project-1", sourceContext, + preparedContext, }), ); @@ -352,7 +3378,7 @@ export default config as const; let revision = "first"; Object.assign(adapter.fs, { getUnderlyingAdapter: () => adapter.fs, - isMultiProjectMode: () => true, + isMultiProjectMode: () => false, isVeryfrontAdapter: () => true, exists: async (path: string) => path === "/veryfront.config.ts", readFile: async () => `export default { title: ${JSON.stringify(revision)} };`, @@ -366,6 +3392,237 @@ export default config as const; assertEquals(second.title, "second"); }); + it("does not coalesce virtual filesystems without an exact source identity", async () => { + for ( + const scenario of [ + { name: "no-cache-key", options: undefined }, + { + name: "contextless-cache-key", + options: { cacheKey: "shared-project" }, + }, + ] as const + ) { + const firstAdapter = setup(); + const secondAdapter = createMockAdapter(); + const firstStarted = Promise.withResolvers(); + const resumeFirst = Promise.withResolvers(); + + Object.assign(firstAdapter.fs, { + getUnderlyingAdapter: () => firstAdapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async () => { + firstStarted.resolve(); + await resumeFirst.promise; + return 'export default { title: "first-filesystem" };'; + }, + }); + Object.assign(secondAdapter.fs, { + getUnderlyingAdapter: () => secondAdapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + exists: async (path: string) => path === "/veryfront.config.ts", + readFile: async () => 'export default { title: "second-filesystem" };', + }); + + const projectDir = `/shared-virtual-project-dir/${scenario.name}`; + const firstRequest = getConfig( + projectDir, + firstAdapter, + scenario.options, + ); + await firstStarted.promise; + const secondRequest = getConfig( + projectDir, + secondAdapter, + scenario.options, + ); + resumeFirst.resolve(); + + const [first, second] = await Promise.all([firstRequest, secondRequest]); + assertEquals(first.title, "first-filesystem"); + assertEquals(second.title, "second-filesystem"); + } + }); + + it("does not coalesce mutable branch sources across virtual filesystem instances", async () => { + const firstAdapter = setup(); + const secondAdapter = createMockAdapter(); + const firstStarted = Promise.withResolvers(); + const resumeFirst = Promise.withResolvers(); + const sourceContext = { + productionMode: false, + branch: "feature/mutable-source", + } as const; + + Object.assign(firstAdapter.fs, { + getUnderlyingAdapter: () => firstAdapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + readFile: async () => { + firstStarted.resolve(); + await resumeFirst.promise; + return 'export default { title: "first-filesystem" };'; + }, + }); + Object.assign(secondAdapter.fs, { + getUnderlyingAdapter: () => secondAdapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + readFile: async () => 'export default { title: "second-filesystem" };', + }); + + const loadFrom = (adapter: ReturnType) => + runWithRequestContext( + { + projectSlug: "shared-project", + projectId: "shared-project", + token: "token", + branch: sourceContext.branch, + }, + () => + getConfig("/shared-mutable-branch", adapter, { + cacheKey: "shared-project", + sourceContext, + }), + ); + + const firstRequest = loadFrom(firstAdapter); + await firstStarted.promise; + const secondRequest = loadFrom(secondAdapter); + resumeFirst.resolve(); + + const [first, second] = await Promise.all([firstRequest, secondRequest]); + assertEquals(first.title, "first-filesystem"); + assertEquals(second.title, "second-filesystem"); + }); + + it("frames trusted-flight source identities without inherited toJSON hooks", async () => { + const adapter = setup(); + const firstReadStarted = Promise.withResolvers(); + const secondReadStarted = Promise.withResolvers(); + const secondLegacyIdentityObserved = Promise.withResolvers(); + const resumeFirstRead = Promise.withResolvers(); + const firstBranch = "feature/framed-source-a"; + const secondBranch = "feature/framed-source-b"; + let reads = 0; + Object.assign(adapter.fs, { + getUnderlyingAdapter: () => adapter.fs, + isMultiProjectMode: () => false, + isVeryfrontAdapter: () => true, + readFile: async (path: string) => { + if (path !== "/veryfront.config.ts") throw configCandidateNotFound(path); + const branch = getCurrentRequestContext()?.branch; + reads += 1; + if (branch === firstBranch) { + firstReadStarted.resolve(); + await resumeFirstRead.promise; + return 'export default { title: "first-branch" };'; + } + if (branch === secondBranch) { + secondReadStarted.resolve(); + return 'export default { title: "second-branch" };'; + } + throw new Error("unexpected trusted config branch"); + }, + }); + + let inheritedIdentityHookCalls = 0; + const restore = [ + definePropertyForTest(Array.prototype, "toJSON", { + value: () => { + inheritedIdentityHookCalls += 1; + throw new Error("inherited Array toJSON must not run"); + }, + writable: true, + }), + definePropertyForTest(Object.prototype, "toJSON", { + value: function (this: object): string { + // Logger serialization deliberately snapshots general toJSON + // values. Count only the normalized source record used by the + // legacy trusted-flight identity serializer. + const productionMode = TestObjectGetOwnPropertyDescriptor( + this, + "productionMode", + ); + const branch = TestObjectGetOwnPropertyDescriptor(this, "branch"); + if ( + productionMode?.value === false && + typeof branch?.value === "string" + ) { + inheritedIdentityHookCalls += 1; + if (inheritedIdentityHookCalls >= 2) { + secondLegacyIdentityObserved.resolve(); + } + return "collapsed-trusted-source-identity"; + } + return "collapsed-trusted-source-identity"; + }, + writable: true, + }), + ]; + const load = (branch: string) => + runWithRequestContext( + { + projectSlug: "framed-trusted-source", + projectId: "framed-trusted-source", + token: "token", + branch, + }, + () => getConfig("/framed-trusted-source", adapter), + ); + + let firstRequest: ReturnType | undefined; + let secondRequest: ReturnType | undefined; + let first: Awaited> | undefined; + let second: Awaited> | undefined; + try { + firstRequest = load(firstBranch); + const firstProgress = await Promise.race([ + firstReadStarted.promise.then(() => "read-started" as const), + firstRequest.then( + () => "request-settled" as const, + () => "request-settled" as const, + ), + ]); + if (firstProgress === "request-settled") { + await firstRequest; + throw new Error("first trusted config request settled before its gated read"); + } + secondRequest = load(secondBranch); + const secondProgress = await Promise.race([ + secondReadStarted.promise.then(() => "read-started" as const), + secondLegacyIdentityObserved.promise.then(() => "legacy-identity" as const), + secondRequest.then( + () => "request-settled" as const, + () => "request-settled" as const, + ), + ]); + if (secondProgress === "request-settled") { + await secondRequest; + throw new Error("second trusted config request settled before identity observation"); + } + resumeFirstRead.resolve(); + [first, second] = await Promise.all([firstRequest, secondRequest]); + } finally { + resumeFirstRead.resolve(); + await Promise.allSettled( + [firstRequest, secondRequest].filter( + (request): request is ReturnType => request !== undefined, + ), + ); + for (let index = restore.length - 1; index >= 0; index -= 1) { + restore[index]!(); + } + } + + assertEquals(inheritedIdentityHookCalls, 0); + assertEquals(reads, 2); + assertEquals(first?.title, "first-branch"); + assertEquals(second?.title, "second-branch"); + }); + it("rejects an explicit source that differs from the request context", async () => { const adapter = setup(); let reads = 0; @@ -379,25 +3636,31 @@ export default config as const; return 'export default { title: "wrong source" };'; }, }); + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: "Production", + environment: {}, + }); await assertRejects( () => runWithRequestContext( { projectSlug: "demo", + projectId: "project-1", token: "token", productionMode: true, environmentName: "Production:release-1", releaseId: "release-2", }, () => - getConfig("/mismatched-source-config", adapter, { + getHostedConfig("/mismatched-source-config", adapter, { cacheKey: "project-1", sourceContext: { productionMode: true, environmentName: "Production", releaseId: "release-1:release-2", }, + preparedContext, }), ), Error, @@ -460,6 +3723,26 @@ export default config as const; assertEquals(getCachedConfigSync("/broken-project"), null); }); + it("preserves schema validation errors instead of relabeling them as parse failures", async () => { + const adapter = setup(); + const projectDir = await Deno.makeTempDir({ prefix: "vf-config-invalid-" }); + const configPath = `${projectDir}/veryfront.config.js`; + const source = 'export default { dev: { port: "not-a-port" } };'; + + try { + await Deno.writeTextFile(configPath, source); + adapter.fs.files.set(configPath, source); + + const error = await assertRejects(() => getConfig(projectDir, adapter)); + + assertEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, "config-validation-failed"); + assertEquals(getCachedConfigSync(projectDir), null); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + it("should produce fresh defaults per call after cache clear", async () => { const adapter = setup(); @@ -502,7 +3785,7 @@ export default config as const; const config = await getConfig("/build-test", adapter); assertEquals(config.build?.trailingSlash, false); assertEquals(config.build?.esbuild?.worker, false); - assert(typeof config.build?.esbuild?.wasmURL === "string"); + assertEquals(config.build?.esbuild?.wasmURL, ESBUILD_WASM_URL); }); it("should include default theme config", async () => { @@ -514,6 +3797,80 @@ export default config as const; }); describe("mergeConfigs deep merge", () => { + it("does not invent inactive filesystem backend configuration", () => { + const merged = mergeConfigs({}); + + assertEquals(merged.fs, { type: "local" }); + }); + + it("keeps only the selected filesystem backend outside proxy mode", () => { + const merged = mergeConfigs({ + fs: { + type: "github", + github: { token: "token", owner: "owner", repo: "repo" }, + }, + }); + + assertEquals(merged.fs, { + type: "github", + github: { token: "token", owner: "owner", repo: "repo" }, + }); + }); + + it("rejects project filesystem overrides when proxy mode owns the backend", () => { + setEnv("PROXY_MODE", "1"); + setEnv("VERYFRONT_API_BASE_URL", "https://api.example.com"); + + assertThrows( + () => mergeConfigs({ fs: { type: "local" } }), + VeryfrontError, + "platform-managed in proxy mode", + ); + + assertEquals(mergeConfigs({}).fs, { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + proxyMode: true, + cache: { enabled: true, ttl: 60_000 }, + retry: { maxRetries: 3, initialDelay: 500, maxDelay: 5_000 }, + }, + }); + }); + + it("fails closed when proxy mode has no valid platform API URL", () => { + setEnv("PROXY_MODE", "1"); + + for ( + const apiBaseUrl of [ + "", + "not-a-url", + "https://token@example.com", + "https://api.example.com/api?target=other", + "https://api.example.com/api#fragment", + ] + ) { + setEnv("VERYFRONT_API_BASE_URL", apiBaseUrl); + assertThrows( + () => mergeConfigs({}), + VeryfrontError, + apiBaseUrl + ? "must be an HTTP(S) base URL without credentials, query, or fragment" + : "requires VERYFRONT_API_BASE_URL", + ); + } + }); + + it("canonicalizes the platform API base URL before consumers concatenate paths", () => { + setEnv("PROXY_MODE", "1"); + setEnv("VERYFRONT_API_BASE_URL", " https://api.example.com/api/// "); + + assertEquals( + mergeConfigs({}).fs?.veryfront?.apiBaseUrl, + "https://api.example.com/api", + ); + }); + it("keeps default cache.render when user overrides only cache.dir", () => { const merged = mergeConfigs({ cache: { dir: "/custom" } }); assertEquals(merged.cache?.dir, "/custom"); @@ -527,7 +3884,7 @@ export default config as const; const merged = mergeConfigs({ build: { outDir: "out" } }); assertEquals(merged.build?.outDir, "out"); assertEquals(merged.build?.esbuild?.worker, false); - assert(typeof merged.build?.esbuild?.wasmURL === "string"); + assertEquals(merged.build?.esbuild?.wasmURL, ESBUILD_WASM_URL); }); it("keeps default theme colors when user sets an unrelated color", () => { diff --git a/src/config/loader.ts b/src/config/loader.ts index 6de4a555b0..d5c7acf5ec 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,28 +1,398 @@ import type { VeryfrontConfig } from "./schemas/index.ts"; -import { findUnknownTopLevelKeys, validateVeryfrontConfig } from "./schemas/index.ts"; -import { extname, join, resolve } from "#veryfront/compat/path/index.ts"; +import { validateVeryfrontConfig } from "./schemas/index.ts"; +import { extname, join, resolve, toFileUrl } from "#veryfront/compat/path/index.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; -import { isVirtualFilesystem } from "#veryfront/platform/adapters/fs/wrapper.ts"; +import { + isExtendedFSAdapter, + isVirtualFilesystem, +} from "#veryfront/platform/adapters/fs/wrapper.ts"; import { isBun, isDenoCompiled } from "#veryfront/platform/compat/runtime.ts"; +import { ESBUILD_WASM_URL } from "#veryfront/platform/compat/esbuild-shared.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; import { getReactImportMap, REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; import { DEFAULT_CACHE_DIR } from "#veryfront/utils/constants/server.ts"; import { buildConfigCacheKey, type VirtualConfigSourceContext } from "#veryfront/cache/keys.ts"; -import { DEFAULT_PORT } from "./defaults.ts"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { DEFAULT_PORT, DEFAULT_RENDER_CACHE_MAX_ENTRIES } from "./defaults.ts"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { CACHE_INVARIANT_VIOLATION, CONFIG_PARSE_ERROR, CONFIG_VALIDATION_FAILED, + INITIALIZATION_ERROR, + SERVICE_OVERLOADED, } from "#veryfront/errors/error-registry.ts"; import { VeryfrontError } from "#veryfront/errors/types.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { SpanNames } from "#veryfront/observability/tracing/span-names.ts"; -import { getEnv } from "#veryfront/platform/compat/process.ts"; +import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; import { registerLRUCache } from "#veryfront/cache/registry.ts"; import { VERYFRONT_CONFIG_FILES } from "./config-files.ts"; import { getCurrentRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; +import type { ModuleLexer } from "#veryfront/extensions/bundler/module-lexer.ts"; +import { tryResolve as tryResolveContract } from "#veryfront/extensions/contracts.ts"; +import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; +import { computeHash } from "#veryfront/utils/hash-utils.ts"; +import { VERYFRONT_CONFIG_SHIM_URL } from "./config-shim.ts"; +import { + createPreparedDeclarativeConfigWorkerPayload, + DeclarativeConfigEvaluationError, + type DeclarativeConfigFileName, + type PreparedDeclarativeConfigContext, + type PreparedDeclarativeConfigWorkerPayload, + prepareDeclarativeConfigContext, +} from "./declarative-evaluator.ts"; +import { + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS, + evaluatePreparedDeclarativeConfigInWorker, +} from "./declarative-evaluator-worker-runner.ts"; +import { createDeclarativeConfigWorkerInfrastructureError } from "./declarative-evaluator-worker-protocol.ts"; + +// Capture the collection and reflection intrinsics before trusted executable +// project configuration can mutate the shared host realm. Hosted configuration +// crosses a tenant boundary later in the same process, so its cache identity, +// singleflight state, and immutable result must not depend on ambient methods. +const IntrinsicMap = Map; +const IntrinsicPromise = Promise; +const IntrinsicTextDecoder = TextDecoder; +const IntrinsicWeakMap = WeakMap; +const IntrinsicWeakSet = WeakSet; +const IntrinsicAbortController = AbortController; +const AbortControllerPrototypeAbort = AbortController.prototype.abort; +const EventTargetPrototypeAddEventListener = EventTarget.prototype.addEventListener; +const EventTargetPrototypeRemoveEventListener = EventTarget.prototype.removeEventListener; +const MapPrototypeClear = Map.prototype.clear; +const MapPrototypeDelete = Map.prototype.delete; +const MapPrototypeForEach = Map.prototype.forEach; +const MapPrototypeGet = Map.prototype.get; +const MapPrototypeSet = Map.prototype.set; +const NumberPrototypeToString = Number.prototype.toString; +const ObjectCreate = Object.create; +const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectIsFrozen = Object.isFrozen; +const PromisePrototypeThen = Promise.prototype.then; +const PromiseReject = Promise.reject; +const PromiseResolve = Promise.resolve; +const PromiseWithResolvers = Promise.withResolvers; +const ReflectApply = Reflect.apply; +const ReflectDeleteProperty = Reflect.deleteProperty; +const ReflectOwnKeys = Reflect.ownKeys; +const SymbolSpecies = Symbol.species; +const TextDecoderPrototypeDecode = TextDecoder.prototype.decode; +const WeakMapPrototypeGet = WeakMap.prototype.get; +const WeakMapPrototypeSet = WeakMap.prototype.set; +const WeakSetPrototypeAdd = WeakSet.prototype.add; +const WeakSetPrototypeHas = WeakSet.prototype.has; +const abortControllerSignalGetter = ObjectGetOwnPropertyDescriptor( + AbortController.prototype, + "signal", +)?.get; +const abortSignalAbortedGetter = ObjectGetOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", +)?.get; +const mapSizeGetter = ObjectGetOwnPropertyDescriptor(Map.prototype, "size")?.get; + +if ( + typeof abortControllerSignalGetter !== "function" || + typeof abortSignalAbortedGetter !== "function" || + typeof mapSizeGetter !== "function" +) { + throw new TypeError("Loader lifecycle intrinsics are unavailable"); +} +const intrinsicAbortControllerSignalGetter = abortControllerSignalGetter as () => AbortSignal; +const intrinsicAbortSignalAbortedGetter = abortSignalAbortedGetter as () => boolean; +const intrinsicMapSizeGetter = mapSizeGetter as () => number; +const HOSTED_CONFIG_TEXT_DECODER_OPTIONS = createHostedConfigTextDecoderOptions(); +const ABORT_LISTENER_OPTIONS = createAbortListenerOptions(); +const SAFE_PROMISE_SPECIES_HOLDER = createSafePromiseSpeciesHolder(); + +function freezeObject(value: T): T { + return ReflectApply(ObjectFreeze, Object, [value]) as T; +} + +function getOwnPropertyDescriptor( + value: object, + key: PropertyKey, +): PropertyDescriptor | undefined { + return ReflectApply(ObjectGetOwnPropertyDescriptor, Object, [value, key]) as + | PropertyDescriptor + | undefined; +} + +function getPrototypeOf(value: object): object | null { + return ReflectApply(ObjectGetPrototypeOf, Object, [value]) as object | null; +} + +function isFrozen(value: object): boolean { + return ReflectApply(ObjectIsFrozen, Object, [value]) as boolean; +} + +function ownKeys(value: object): PropertyKey[] { + return ReflectApply(ReflectOwnKeys, Reflect, [value]) as PropertyKey[]; +} + +function mapGet(map: Map, key: K): V | undefined { + return ReflectApply(MapPrototypeGet, map, [key]) as V | undefined; +} + +function mapSet(map: Map, key: K, value: V): void { + ReflectApply(MapPrototypeSet, map, [key, value]); +} + +function mapDelete(map: Map, key: K): boolean { + return ReflectApply(MapPrototypeDelete, map, [key]) as boolean; +} + +function mapClear(map: Map): void { + ReflectApply(MapPrototypeClear, map, []); +} + +function mapSize(map: Map): number { + return ReflectApply(intrinsicMapSizeGetter, map, []) as number; +} + +function mapForEach( + map: Map, + callback: (value: V, key: K) => void, +): void { + ReflectApply(MapPrototypeForEach, map, [callback]); +} + +function weakMapGet( + map: WeakMap, + key: K, +): V | undefined { + return ReflectApply(WeakMapPrototypeGet, map, [key]) as V | undefined; +} + +function weakMapSet( + map: WeakMap, + key: K, + value: V, +): void { + ReflectApply(WeakMapPrototypeSet, map, [key, value]); +} + +function weakSetHas(set: WeakSet, value: T): boolean { + return ReflectApply(WeakSetPrototypeHas, set, [value]) as boolean; +} + +function weakSetAdd(set: WeakSet, value: T): void { + ReflectApply(WeakSetPrototypeAdd, set, [value]); +} + +function createNullPrototypeDescriptor(): PropertyDescriptor { + return ReflectApply(ObjectCreate, Object, [null]) as PropertyDescriptor; +} + +function createNullPrototypeRecord(): T { + return ReflectApply(ObjectCreate, Object, [null]) as T; +} + +function defineOwnDataProperty( + target: object, + key: PropertyKey, + value: unknown, +): void { + const descriptor = createNullPrototypeDescriptor(); + descriptor.value = value; + descriptor.writable = false; + descriptor.enumerable = false; + descriptor.configurable = false; + ReflectApply(ObjectDefineProperty, Object, [target, key, descriptor]); +} + +function defineOwnTemporaryDataProperty( + target: object, + key: PropertyKey, + value: unknown, +): void { + const descriptor = createNullPrototypeDescriptor(); + descriptor.value = value; + descriptor.writable = false; + descriptor.enumerable = false; + descriptor.configurable = true; + ReflectApply(ObjectDefineProperty, Object, [target, key, descriptor]); +} + +function defineOwnGetterProperty( + target: object, + key: PropertyKey, + getter: () => unknown, +): void { + const descriptor = createNullPrototypeDescriptor(); + descriptor.get = getter; + descriptor.enumerable = false; + descriptor.configurable = false; + ReflectApply(ObjectDefineProperty, Object, [target, key, descriptor]); +} + +function createHostedConfigTextDecoderOptions(): TextDecoderOptions { + const options = createNullPrototypeRecord(); + defineOwnDataProperty(options, "fatal", true); + defineOwnDataProperty(options, "ignoreBOM", false); + return freezeObject(options); +} + +function createAbortListenerOptions(): AddEventListenerOptions { + const options = createNullPrototypeRecord(); + defineOwnDataProperty(options, "capture", false); + defineOwnDataProperty(options, "once", true); + defineOwnDataProperty(options, "passive", false); + return freezeObject(options); +} + +function createSafePromiseSpeciesHolder(): object { + const holder = createNullPrototypeRecord(); + defineOwnDataProperty(holder, SymbolSpecies, IntrinsicPromise); + return freezeObject(holder); +} + +function restoreOwnPropertyDescriptor( + target: object, + key: PropertyKey, + descriptor: PropertyDescriptor, +): void { + const safeDescriptor = createNullPrototypeDescriptor(); + const value = getOwnPropertyDescriptor(descriptor, "value"); + const writable = getOwnPropertyDescriptor(descriptor, "writable"); + const getter = getOwnPropertyDescriptor(descriptor, "get"); + const setter = getOwnPropertyDescriptor(descriptor, "set"); + const enumerable = getOwnPropertyDescriptor(descriptor, "enumerable"); + const configurable = getOwnPropertyDescriptor(descriptor, "configurable"); + if (value) safeDescriptor.value = value.value; + if (writable) safeDescriptor.writable = writable.value as boolean; + if (getter) safeDescriptor.get = getter.value as (() => unknown) | undefined; + if (setter) safeDescriptor.set = setter.value as ((value: unknown) => void) | undefined; + if (enumerable) safeDescriptor.enumerable = enumerable.value as boolean; + if (configurable) safeDescriptor.configurable = configurable.value as boolean; + ReflectApply(ObjectDefineProperty, Object, [target, key, safeDescriptor]); +} + +function callPromiseThen( + promise: Promise, + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, +): Promise { + const originalConstructor = getOwnPropertyDescriptor(promise, "constructor"); + if (originalConstructor?.configurable === false) { + throw new TypeError("Cannot safely observe a promise with a fixed constructor"); + } + + defineOwnTemporaryDataProperty( + promise, + "constructor", + SAFE_PROMISE_SPECIES_HOLDER, + ); + try { + return ReflectApply(PromisePrototypeThen, promise, [ + onFulfilled, + onRejected, + ]) as Promise; + } finally { + if (originalConstructor) { + restoreOwnPropertyDescriptor( + promise, + "constructor", + originalConstructor, + ); + } else { + ReflectApply(ReflectDeleteProperty, Reflect, [promise, "constructor"]); + } + } +} + +function getAbortControllerSignal(controller: AbortController): AbortSignal { + return ReflectApply( + intrinsicAbortControllerSignalGetter, + controller, + [], + ) as AbortSignal; +} + +function abortController(controller: AbortController): void { + ReflectApply(AbortControllerPrototypeAbort, controller, []); +} + +function isSignalAborted(signal: AbortSignal): boolean { + return ReflectApply(intrinsicAbortSignalAbortedGetter, signal, []) as boolean; +} + +function intrinsicSignalAbortedOwnGetter(this: AbortSignal): boolean { + return isSignalAborted(this); +} + +function createHostedAbortController(): AbortController { + const controller = new IntrinsicAbortController(); + const signal = getAbortControllerSignal(controller); + // Deno's AbortController implementation dynamically reads both public + // properties while aborting. Own properties keep that native path bound to + // captured getters even if trusted executable config later poisons either + // prototype. Null-prototype descriptors prevent inherited descriptor hooks. + defineOwnDataProperty(controller, "signal", signal); + defineOwnGetterProperty( + signal, + "aborted", + intrinsicSignalAbortedOwnGetter, + ); + return controller; +} + +function addAbortListener(signal: AbortSignal, listener: () => void): void { + ReflectApply(EventTargetPrototypeAddEventListener, signal, [ + "abort", + listener, + ABORT_LISTENER_OPTIONS, + ]); +} + +function removeAbortListener(signal: AbortSignal, listener: () => void): void { + ReflectApply(EventTargetPrototypeRemoveEventListener, signal, [ + "abort", + listener, + ]); +} + +function deferPromise(operation: () => T | PromiseLike): Promise> { + const ready = ReflectApply(PromiseResolve, IntrinsicPromise, []) as Promise; + return callPromiseThen(ready, operation) as Promise>; +} + +function rejectPromise(error: unknown): Promise { + return ReflectApply(PromiseReject, IntrinsicPromise, [error]) as Promise; +} + +function promiseWithResolvers(): PromiseWithResolvers { + return ReflectApply(PromiseWithResolvers, IntrinsicPromise, []) as PromiseWithResolvers; +} + +function thenPromise( + promise: Promise, + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, +): Promise { + return callPromiseThen(promise, onFulfilled, onRejected); +} + +function decimalIdentityNumber(value: number): string { + return ReflectApply(NumberPrototypeToString, value, [10]) as string; +} + +function frameConfigIdentityString(value: string): string { + return `${decimalIdentityNumber(value.length)}:${value}`; +} + +function frameOptionalConfigIdentityString( + value: string | null | undefined, +): string { + return value === null || value === undefined + ? "absent;" + : `value:${frameConfigIdentityString(value)}`; +} const logger = serverLogger.component("config"); @@ -34,8 +404,6 @@ const DEFAULT_FS_MAX_RETRIES = 3; const DEFAULT_FS_INITIAL_DELAY_MS = 500; /** Maximum backoff delay between retries */ const DEFAULT_FS_MAX_DELAY_MS = 5_000; -/** Maximum entries in the render cache */ -const DEFAULT_RENDER_CACHE_MAX_ENTRIES = 500; /** Maximum entries in the per-project config cache */ const DEFAULT_CONFIG_CACHE_MAX_ENTRIES = 100; @@ -52,28 +420,58 @@ function getDefaultImportMapForConfig(): { imports: ReturnType { outDir: "dist", trailingSlash: false, esbuild: { - wasmURL: "https://deno.land/x/esbuild@v0.20.1/esbuild.wasm", + wasmURL: ESBUILD_WASM_URL, worker: false, }, }, @@ -127,8 +525,6 @@ function createFreshDefaults(): Partial { ttl: undefined, maxEntries: DEFAULT_RENDER_CACHE_MAX_ENTRIES, kvPath: undefined, - redisUrl: undefined, - redisKeyPrefix: undefined, }, }, dev: { @@ -150,71 +546,809 @@ function createFreshDefaults(): Partial { }; } -const configCacheByProject = new LRUCache({ +export type ConfigLoadProvenance = + | Readonly<{ kind: "file"; configFile: DeclarativeConfigFileName }> + | Readonly<{ kind: "defaults" }>; + +export interface ConfigLoadResult { + readonly config: VeryfrontConfig; + readonly provenance: ConfigLoadProvenance; +} + +interface ConfigCacheEntry { + readonly revision: number; + readonly config: VeryfrontConfig; + readonly provenance: ConfigLoadProvenance; +} + +const configCacheByProject = new LRUCache({ maxEntries: DEFAULT_CONFIG_CACHE_MAX_ENTRIES, }); +type HostedConfigEvaluator = typeof evaluatePreparedDeclarativeConfigInWorker; + +interface HostedConfigSourceSelection { + readonly configPath: string; + readonly configFile: DeclarativeConfigFileName; + readonly source: string; +} + +type HostedConfigSourceReadKey = string | object; +type HostedConfigSourceReadState = "queued" | "active" | "ready" | "failed"; + +interface HostedConfigSourceReadFlight { + readonly key: HostedConfigSourceReadKey; + readonly start: PromiseWithResolvers; + readonly promise: Promise; + queueNode: HostedConfigSourceReadQueueNode | null; + waiterCount: number; + state: HostedConfigSourceReadState; +} + +interface HostedConfigSourceReadQueueNode { + flight: HostedConfigSourceReadFlight; + previous: HostedConfigSourceReadQueueNode | null; + next: HostedConfigSourceReadQueueNode | null; +} + +interface HostedConfigSourceReadLease { + readonly selection: HostedConfigSourceSelection | null; + readonly release: () => void; +} + +const MAX_ACTIVE_HOSTED_CONFIG_SOURCE_READS = DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxActive; +const MAX_QUEUED_HOSTED_CONFIG_SOURCE_READS = DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxQueued; +const hostedConfigSourceReadFlights = new IntrinsicMap< + HostedConfigSourceReadKey, + HostedConfigSourceReadFlight +>(); +let hostedConfigSourceReadQueueHead: HostedConfigSourceReadQueueNode | null = null; +let hostedConfigSourceReadQueueTail: HostedConfigSourceReadQueueNode | null = null; +let queuedHostedConfigSourceReads = 0; +const hostedConfigSourceReadFilesystemIds = new IntrinsicWeakMap(); +let nextHostedConfigSourceReadFilesystemId = 1; +let activeHostedConfigSourceReads = 0; + +interface HostedConfigFlight { + readonly controller: AbortController; + readonly promise: Promise; + waiterCount: number; + settled: boolean; +} + +const MAX_HOSTED_CONFIG_FLIGHTS = DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxActive + + DECLARATIVE_CONFIG_WORKER_ADMISSION_LIMITS.maxQueued; +const hostedConfigFlights = new IntrinsicMap(); +let hostedConfigEvaluator: HostedConfigEvaluator = evaluatePreparedDeclarativeConfigInWorker; + +interface TrustedConfigFlight { + readonly promise: Promise; +} + +const MAX_TRUSTED_CONFIG_FLIGHTS = 64; +const trustedConfigFlights = new IntrinsicMap(); +const trustedVirtualFilesystemIds = new IntrinsicWeakMap(); +let nextTrustedVirtualFilesystemId = 1; + // Register cache for monitoring registerLRUCache("config-cache", configCacheByProject); let cacheRevision = 0; -function validateCorsConfig(userConfig: unknown): void { - if (!userConfig || typeof userConfig !== "object") return; +function configFileProvenance( + configFile: DeclarativeConfigFileName, +): ConfigLoadProvenance { + return freezeObject({ kind: "file", configFile }); +} + +function defaultConfigProvenance(): ConfigLoadProvenance { + return freezeObject({ kind: "defaults" }); +} + +function createConfigLoadResult( + config: VeryfrontConfig, + provenance: ConfigLoadProvenance, +): ConfigLoadResult { + return freezeObject({ config, provenance }); +} + +function buildTrustedConfigFlightKey( + effectiveCacheKey: string, + revision: number, +): string { + return `${revision}:${effectiveCacheKey}`; +} + +function getOrCreateTrustedConfigFlight( + effectiveCacheKey: string, + revision: number, + operation: () => Promise, +): Promise { + const flightKey = buildTrustedConfigFlightKey(effectiveCacheKey, revision); + const existing = mapGet(trustedConfigFlights, flightKey); + if (existing) return existing.promise; + + if (mapSize(trustedConfigFlights) >= MAX_TRUSTED_CONFIG_FLIGHTS) { + throw SERVICE_OVERLOADED.create({ + detail: `Too many concurrent trusted configuration loads (${MAX_TRUSTED_CONFIG_FLIGHTS})`, + }); + } + + const flight: TrustedConfigFlight = { + promise: deferPromise(operation), + }; + mapSet(trustedConfigFlights, flightKey, flight); + + const finish = (): void => { + if (mapGet(trustedConfigFlights, flightKey) === flight) { + mapDelete(trustedConfigFlights, flightKey); + } + }; + void thenPromise(flight.promise, finish, finish); + return flight.promise; +} + +function isHostedMultiProjectFilesystem(adapter: RuntimeAdapter): boolean { + return isExtendedFSAdapter(adapter.fs) && adapter.fs.isMultiProjectMode(); +} + +function throwIfHostedConfigAborted(signal: AbortSignal | undefined): void { + if (signal && isSignalAborted(signal)) { + throw createDeclarativeConfigWorkerInfrastructureError("worker-aborted"); + } +} + +function decodeConfigSource(content: string | Uint8Array): string { + if (typeof content === "string") return content; + const decoder = new IntrinsicTextDecoder( + "utf-8", + HOSTED_CONFIG_TEXT_DECODER_OPTIONS, + ); + return ReflectApply(TextDecoderPrototypeDecode, decoder, [content]) as string; +} + +function decodeTrustedConfigSource(content: string | Uint8Array): string { + if (typeof content === "string") return content; + const decoder = new IntrinsicTextDecoder(); + return ReflectApply(TextDecoderPrototypeDecode, decoder, [content]) as string; +} + +function hostedConfigSourceReadFilesystemId(adapter: RuntimeAdapter): number { + const filesystem = adapter.fs as object; + let filesystemId = weakMapGet(hostedConfigSourceReadFilesystemIds, filesystem); + if (filesystemId === undefined) { + filesystemId = nextHostedConfigSourceReadFilesystemId; + nextHostedConfigSourceReadFilesystemId += 1; + weakMapSet(hostedConfigSourceReadFilesystemIds, filesystem, filesystemId); + } + return filesystemId; +} + +function buildHostedConfigSourceReadKey( + effectiveCacheKey: string, + configBaseDir: string, + adapter: RuntimeAdapter, + sourceContext: VirtualConfigSourceContext, + revisionAtStart: number, +): HostedConfigSourceReadKey { + // Branch names identify mutable pointers, not immutable source snapshots. + // Giving every preview request a fresh identity keeps reads independently + // observable while still routing them through the shared admission budget. + if (!sourceContext.productionMode) return freezeObject({}); + + const filesystemId = hostedConfigSourceReadFilesystemId(adapter); + return `hosted-config-source-read-v1:${ + frameConfigIdentityString(decimalIdentityNumber(filesystemId)) + }${frameConfigIdentityString(effectiveCacheKey)}${frameConfigIdentityString(configBaseDir)}${ + frameConfigIdentityString(decimalIdentityNumber(revisionAtStart)) + }`; +} - const cfg = userConfig as Record; - const security = cfg.security as Record | undefined; - if (!security) return; +async function readHostedConfigSource( + adapter: RuntimeAdapter, + configBaseDir: string, +): Promise { + for (const configFile of VERYFRONT_CONFIG_FILES) { + const configPath = join(configBaseDir, configFile); + try { + const content = await adapter.fs.readFile(configPath); + return freezeObject({ + configPath, + configFile, + source: decodeConfigSource(content), + }); + } catch (error) { + if (isNotFoundError(error)) { + logger.debug("Hosted config candidate not found", { configPath }); + continue; + } + if (error instanceof DeclarativeConfigEvaluationError) { + throw translateHostedConfigEvaluationError(error, configFile); + } + if (isPreservedConfigLoadError(error)) throw error; + logger.warn("Failed to load config file", { configFile }); + throw CONFIG_PARSE_ERROR.create({ + detail: `Failed to load ${configFile}`, + cause: error, + context: { configFile }, + }); + } + } + return null; +} - const cors = security.cors; - if (!cors || typeof cors !== "object" || Array.isArray(cors)) return; +function removeQueuedHostedConfigSourceRead( + flight: HostedConfigSourceReadFlight, +): void { + const node = flight.queueNode; + if (!node) return; + if (node.previous) node.previous.next = node.next; + else hostedConfigSourceReadQueueHead = node.next; + if (node.next) node.next.previous = node.previous; + else hostedConfigSourceReadQueueTail = node.previous; + node.previous = null; + node.next = null; + flight.queueNode = null; + queuedHostedConfigSourceReads -= 1; +} - const origin = (cors as Record).origin; - if (origin === undefined || typeof origin === "string") return; +function enqueueHostedConfigSourceRead( + flight: HostedConfigSourceReadFlight, +): void { + const node = createNullPrototypeRecord(); + node.flight = flight; + node.previous = hostedConfigSourceReadQueueTail; + node.next = null; + if (hostedConfigSourceReadQueueTail) { + hostedConfigSourceReadQueueTail.next = node; + } else { + hostedConfigSourceReadQueueHead = node; + } + hostedConfigSourceReadQueueTail = node; + flight.queueNode = node; + queuedHostedConfigSourceReads += 1; +} - throw CONFIG_VALIDATION_FAILED.create({ - detail: "security.cors.origin must be a string. Expected boolean or { origin?: string }", +function dequeueHostedConfigSourceRead(): HostedConfigSourceReadFlight | undefined { + const node = hostedConfigSourceReadQueueHead; + if (!node) return undefined; + const flight = node.flight; + removeQueuedHostedConfigSourceRead(flight); + return flight; +} + +function dispatchHostedConfigSourceReads(): void { + while ( + activeHostedConfigSourceReads < MAX_ACTIVE_HOSTED_CONFIG_SOURCE_READS && + queuedHostedConfigSourceReads > 0 + ) { + const flight = dequeueHostedConfigSourceRead(); + if (!flight) { + throw new TypeError("Hosted config source-read queue state is inconsistent"); + } + if (flight.state !== "queued") continue; + flight.state = "active"; + activeHostedConfigSourceReads += 1; + flight.start.resolve(); + } +} + +function finishHostedConfigSourceRead( + flight: HostedConfigSourceReadFlight, + succeeded: boolean, +): void { + if (flight.state !== "active") return; + flight.state = succeeded ? "ready" : "failed"; + if ( + (!succeeded || flight.waiterCount === 0) && + mapGet(hostedConfigSourceReadFlights, flight.key) === flight + ) { + mapDelete(hostedConfigSourceReadFlights, flight.key); + } + activeHostedConfigSourceReads -= 1; + dispatchHostedConfigSourceReads(); +} + +function cancelQueuedHostedConfigSourceRead( + flight: HostedConfigSourceReadFlight, + error: unknown, +): void { + if (flight.state !== "queued") return; + flight.state = "failed"; + removeQueuedHostedConfigSourceRead(flight); + if (mapGet(hostedConfigSourceReadFlights, flight.key) === flight) { + mapDelete(hostedConfigSourceReadFlights, flight.key); + } + // Rejecting the start gate settles the already-observed operation promise + // without ever invoking the filesystem adapter. + flight.start.reject(error); +} + +function createHostedConfigSourceReadFlight( + key: HostedConfigSourceReadKey, + operation: () => Promise, +): HostedConfigSourceReadFlight { + const start = promiseWithResolvers(); + // Register the deferred operation in the caller's async context now. A + // queued multi-project read must not inherit the request context of whichever + // earlier flight later releases capacity. + const promise = thenPromise(start.promise, operation) as unknown as Promise< + HostedConfigSourceSelection | null + >; + const flight: HostedConfigSourceReadFlight = { + key, + start, + promise, + queueNode: null, + waiterCount: 0, + state: "queued", + }; + mapSet(hostedConfigSourceReadFlights, key, flight); + void thenPromise( + promise, + () => finishHostedConfigSourceRead(flight, true), + () => finishHostedConfigSourceRead(flight, false), + ); + + if (activeHostedConfigSourceReads < MAX_ACTIVE_HOSTED_CONFIG_SOURCE_READS) { + flight.state = "active"; + activeHostedConfigSourceReads += 1; + start.resolve(); + } else { + enqueueHostedConfigSourceRead(flight); + } + return flight; +} + +function getOrCreateHostedConfigSourceReadFlight( + key: HostedConfigSourceReadKey, + operation: () => Promise, +): HostedConfigSourceReadFlight { + const existing = mapGet(hostedConfigSourceReadFlights, key); + if (existing && existing.state !== "failed") return existing; + if (existing) mapDelete(hostedConfigSourceReadFlights, key); + + if ( + activeHostedConfigSourceReads >= MAX_ACTIVE_HOSTED_CONFIG_SOURCE_READS && + queuedHostedConfigSourceReads >= MAX_QUEUED_HOSTED_CONFIG_SOURCE_READS + ) { + throw createDeclarativeConfigWorkerInfrastructureError("worker-overloaded"); + } + return createHostedConfigSourceReadFlight(key, operation); +} + +function releaseHostedConfigSourceReadLease( + flight: HostedConfigSourceReadFlight, +): void { + flight.waiterCount -= 1; + if (flight.waiterCount !== 0) return; + if (flight.state === "queued") { + cancelQueuedHostedConfigSourceRead( + flight, + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + } else if ( + flight.state === "ready" && + mapGet(hostedConfigSourceReadFlights, flight.key) === flight + ) { + mapDelete(hostedConfigSourceReadFlights, flight.key); + } +} + +function waitForHostedConfigSourceReadFlight( + flight: HostedConfigSourceReadFlight, + signal: AbortSignal | undefined, +): Promise { + if (signal && isSignalAborted(signal)) { + if (flight.waiterCount === 0) { + cancelQueuedHostedConfigSourceRead( + flight, + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + } + return rejectPromise( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + } + + flight.waiterCount += 1; + return new IntrinsicPromise((resolve, reject) => { + let settled = false; + let released = false; + let listenerAttached = false; + const release = (): void => { + if (released) return; + released = true; + releaseHostedConfigSourceReadLease(flight); + }; + const detachAbortListener = (): void => { + if (!signal || !listenerAttached) return; + listenerAttached = false; + removeAbortListener(signal, onAbort); + }; + const finish = (settleWaiter: () => void): void => { + if (settled) return; + settled = true; + try { + detachAbortListener(); + } catch (error) { + release(); + reject(error); + return; + } + settleWaiter(); + }; + const onAbort = (): void => { + finish(() => { + release(); + reject( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + }); + }; + + try { + if (signal) { + // Mark the listener as attached first so a partially completed native + // registration can still be rolled back if WebIDL conversion throws. + listenerAttached = true; + addAbortListener(signal, onAbort); + } + void thenPromise( + flight.promise, + (selection) => + finish(() => + resolve(freezeObject({ + selection, + release, + })) + ), + (error: unknown) => + finish(() => { + release(); + reject(error); + }), + ); + if (signal && isSignalAborted(signal)) onAbort(); + } catch (error) { + if (settled) return; + settled = true; + try { + detachAbortListener(); + } catch { + // Preserve the primary listener/reaction setup failure. + } + release(); + reject(error); + } }); } -function validateConfigShape(userConfig: unknown): VeryfrontConfig { - const validatedConfig = validateVeryfrontConfig(userConfig) as VeryfrontConfig; - if (!userConfig || typeof userConfig !== "object") return validatedConfig; +function deepFreezeHostedConfig(config: VeryfrontConfig): VeryfrontConfig { + const seen = new IntrinsicWeakSet(); + const visit = (value: unknown): void => { + if ((typeof value !== "object" && typeof value !== "function") || value === null) return; + if (weakSetHas(seen, value)) return; + weakSetAdd(seen, value); + for (const key of ownKeys(value)) { + const descriptor = getOwnPropertyDescriptor(value, key); + if (descriptor && "value" in descriptor) visit(descriptor.value); + } + freezeObject(value); + }; + visit(config); + return config; +} - const unknown = findUnknownTopLevelKeys(userConfig as Record); - if (unknown.length > 0) { - throw CONFIG_VALIDATION_FAILED.create({ - detail: `Unknown config keys: ${unknown.join(", ")}. Check for typos in veryfront.config.`, +async function buildHostedConfigCacheKey( + baseCacheKey: string, + configPath: string, + source: string, + payload: PreparedDeclarativeConfigWorkerPayload, +): Promise { + const sourceDigest = await computeHash(source); + const identityMaterial = `veryfront-hosted-config-cache-v2:${ + frameConfigIdentityString(configPath) + }${frameConfigIdentityString(sourceDigest)}${frameConfigIdentityString(payload.policyVersion)}${ + frameConfigIdentityString(payload.cacheFingerprint) + }`; + const identityDigest = await computeHash(identityMaterial); + return `${baseCacheKey}:hosted:${identityDigest}`; +} + +function buildHostedConfigFlightKey(hostedCacheKey: string, revision: number): string { + return `${revision}:${hostedCacheKey}`; +} + +function createHostedConfigFlight( + flightKey: string, + hostedCacheKey: string, + payload: PreparedDeclarativeConfigWorkerPayload, + usePersistentCache: boolean, + revisionAtStart: number, +): HostedConfigFlight { + const controller = createHostedAbortController(); + const controllerSignal = getAbortControllerSignal(controller); + const result = promiseWithResolvers(); + const flight: HostedConfigFlight = { + controller, + promise: result.promise, + waiterCount: 0, + settled: false, + }; + const operation = deferPromise(async () => { + throwIfHostedConfigAborted(controllerSignal); + const snapshot = await hostedConfigEvaluator(payload, { + signal: controllerSignal, }); + throwIfHostedConfigAborted(controllerSignal); + const merged = deepFreezeHostedConfig(validateAndMergeConfig(snapshot)); + throwIfHostedConfigAborted(controllerSignal); + if (usePersistentCache && cacheRevision === revisionAtStart) { + configCacheByProject.set(hostedCacheKey, { + revision: revisionAtStart, + config: merged, + provenance: configFileProvenance(payload.evaluationOptions.fileName), + }); + } + return merged; + }); + + const finish = (): void => { + flight.settled = true; + if (mapGet(hostedConfigFlights, flightKey) === flight) { + mapDelete(hostedConfigFlights, flightKey); + } + }; + void thenPromise( + operation, + (config) => { + finish(); + result.resolve(config); + }, + (error: unknown) => { + finish(); + result.reject(error); + }, + ); + mapSet(hostedConfigFlights, flightKey, flight); + return flight; +} + +function getOrCreateHostedConfigFlight( + hostedCacheKey: string, + payload: PreparedDeclarativeConfigWorkerPayload, + usePersistentCache: boolean, + revisionAtStart: number, +): HostedConfigFlight { + const flightKey = buildHostedConfigFlightKey(hostedCacheKey, revisionAtStart); + const existing = mapGet(hostedConfigFlights, flightKey); + if ( + existing && + !isSignalAborted(getAbortControllerSignal(existing.controller)) + ) { + return existing; + } + if (existing) mapDelete(hostedConfigFlights, flightKey); + + if (mapSize(hostedConfigFlights) >= MAX_HOSTED_CONFIG_FLIGHTS) { + throw createDeclarativeConfigWorkerInfrastructureError("worker-overloaded"); + } + + return createHostedConfigFlight( + flightKey, + hostedCacheKey, + payload, + usePersistentCache, + revisionAtStart, + ); +} + +function waitForHostedConfigFlight( + flight: HostedConfigFlight, + signal: AbortSignal | undefined, +): Promise { + if (signal && isSignalAborted(signal)) { + return rejectPromise( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ); + } + + flight.waiterCount += 1; + return new IntrinsicPromise((resolve, reject) => { + let settled = false; + let released = false; + let listenerAttached = false; + const releaseWaiter = (): void => { + if (released) return; + released = true; + flight.waiterCount -= 1; + if (flight.waiterCount === 0 && !flight.settled) { + abortController(flight.controller); + } + }; + const detachAbortListener = (): void => { + if (!signal || !listenerAttached) return; + listenerAttached = false; + removeAbortListener(signal, onAbort); + }; + const finish = (settleWaiter: () => void): void => { + if (settled) return; + settled = true; + let detachFailed = false; + let detachError: unknown; + try { + detachAbortListener(); + } catch (error) { + detachFailed = true; + detachError = error; + } + try { + releaseWaiter(); + } catch (error) { + reject(detachFailed ? detachError : error); + return; + } + if (detachFailed) { + reject(detachError); + return; + } + settleWaiter(); + }; + const onAbort = (): void => { + finish(() => + reject( + createDeclarativeConfigWorkerInfrastructureError("worker-aborted"), + ) + ); + }; + + try { + if (signal) { + listenerAttached = true; + addAbortListener(signal, onAbort); + } + void thenPromise( + flight.promise, + (config) => finish(() => resolve(config)), + (error: unknown) => finish(() => reject(error)), + ); + if (signal && isSignalAborted(signal)) onAbort(); + } catch (error) { + if (settled) return; + settled = true; + try { + detachAbortListener(); + } catch { + // Preserve the primary listener/reaction setup failure. + } + try { + releaseWaiter(); + } catch { + // Preserve the primary listener/reaction setup failure. + } + reject(error); + } + }); +} + +function validateConfigShape(userConfig: unknown): VeryfrontConfig { + return validateVeryfrontConfig(userConfig) as VeryfrontConfig; +} + +const FILESYSTEM_BACKEND_KEYS = ["local", "veryfront", "memory", "github"] as const; +type FilesystemBackendKey = (typeof FILESYSTEM_BACKEND_KEYS)[number]; + +function filesystemBackendKey( + type: string, +): FilesystemBackendKey { + switch (type) { + case "local": + case "memory": + case "github": + return type; + case "veryfront-api": + return "veryfront"; + default: + throw CONFIG_VALIDATION_FAILED.create({ + detail: `Unsupported filesystem backend "${type}"`, + }); + } +} + +function mergeFilesystemConfig( + defaults: VeryfrontConfig["fs"], + userConfig: VeryfrontConfig["fs"], +): NonNullable { + if (defaults?.type === "veryfront-api" && defaults.veryfront?.proxyMode === true) { + if (userConfig && Object.keys(userConfig).length > 0) { + throw CONFIG_VALIDATION_FAILED.create({ + detail: + "Filesystem configuration is platform-managed in proxy mode and cannot be overridden by project config", + }); + } + return { + ...defaults, + veryfront: { + ...defaults.veryfront, + ...(defaults.veryfront.cache ? { cache: { ...defaults.veryfront.cache } } : {}), + ...(defaults.veryfront.retry ? { retry: { ...defaults.veryfront.retry } } : {}), + }, + }; + } + + const selectedType = userConfig?.type ?? defaults?.type ?? "local"; + const selectedKey = filesystemBackendKey(selectedType); + for (const key of FILESYSTEM_BACKEND_KEYS) { + if (key !== selectedKey && userConfig?.[key] !== undefined) { + throw CONFIG_VALIDATION_FAILED.create({ + detail: `Filesystem options for "${key}" do not match selected backend "${selectedType}"`, + }); + } + } + + const selectedDefaults = defaults?.type === selectedType ? defaults : undefined; + const merged: NonNullable = { + type: selectedType, + }; + + if (selectedKey === "local" && (selectedDefaults?.local || userConfig?.local)) { + merged.local = { ...selectedDefaults?.local, ...userConfig?.local }; + } else if (selectedKey === "memory" && (selectedDefaults?.memory || userConfig?.memory)) { + merged.memory = { ...selectedDefaults?.memory, ...userConfig?.memory }; + } else if ( + selectedKey === "veryfront" && + (selectedDefaults?.veryfront || userConfig?.veryfront) + ) { + const veryfront = { + ...selectedDefaults?.veryfront, + ...userConfig?.veryfront, + } as NonNullable["veryfront"]>; + if (selectedDefaults?.veryfront?.cache || userConfig?.veryfront?.cache) { + veryfront.cache = { + ...selectedDefaults?.veryfront?.cache, + ...userConfig?.veryfront?.cache, + }; + } + if (selectedDefaults?.veryfront?.retry || userConfig?.veryfront?.retry) { + veryfront.retry = { + ...selectedDefaults?.veryfront?.retry, + ...userConfig?.veryfront?.retry, + }; + } + merged.veryfront = veryfront; + } else if (selectedKey === "github" && (selectedDefaults?.github || userConfig?.github)) { + const github = { + ...selectedDefaults?.github, + ...userConfig?.github, + } as NonNullable["github"]>; + if (selectedDefaults?.github?.cache || userConfig?.github?.cache) { + github.cache = { + ...selectedDefaults?.github?.cache, + ...userConfig?.github?.cache, + }; + } + if (selectedDefaults?.github?.retry || userConfig?.github?.retry) { + github.retry = { + ...selectedDefaults?.github?.retry, + ...userConfig?.github?.retry, + }; + } + merged.github = github; } - return userConfig as VeryfrontConfig; + + return merged; } /** @internal Exported for tests: merges user config over fresh defaults (deep for nested objects). */ export function mergeConfigs(userConfig: Partial): VeryfrontConfig { const defaults = createFreshDefaults(); + const mergedFs = mergeFilesystemConfig(defaults.fs, userConfig.fs); const merged = { ...defaults, ...userConfig, - fs: { - ...defaults.fs, - ...userConfig.fs, - veryfront: { - ...defaults.fs?.veryfront, - ...userConfig.fs?.veryfront, - // Nested sub-objects would otherwise be replaced wholesale by a partial - // user override, dropping the default cache/retry fields. - cache: { - ...defaults.fs?.veryfront?.cache, - ...userConfig.fs?.veryfront?.cache, - }, - retry: { - ...defaults.fs?.veryfront?.retry, - ...userConfig.fs?.veryfront?.retry, - }, - }, - }, + fs: mergedFs, dev: { ...defaults.dev, ...userConfig.dev, @@ -281,13 +1415,6 @@ export function mergeConfigs(userConfig: Partial): VeryfrontCon } function validateAndMergeConfig(userConfig: unknown): VeryfrontConfig { - if (!userConfig || typeof userConfig !== "object" || Array.isArray(userConfig)) { - throw CONFIG_VALIDATION_FAILED.create({ - detail: `Expected object, received ${userConfig === null ? "null" : typeof userConfig}`, - }); - } - - validateCorsConfig(userConfig); const normalizedConfig = validateConfigShape(userConfig); const merged = mergeConfigs(normalizedConfig); @@ -299,15 +1426,66 @@ function validateAndMergeConfig(userConfig: unknown): VeryfrontConfig { return merged; } -function isConfigError(error: unknown): boolean { - // Prefer the structured slug check. The message-prefix check is only a fallback - // for errors thrown before they were migrated to the VeryfrontError registry; - // it is intentionally narrow to avoid misclassifying third-party errors. - if ( - error instanceof VeryfrontError && - (error.slug === "config-validation-failed" || error.slug === "config-parse-error") - ) return true; - return error instanceof Error && error.message.startsWith("Invalid veryfront.config"); +/** + * Select the authored configuration value from an imported module namespace. + * + * Presence, rather than truthiness, determines whether the default export is + * authoritative. This preserves explicit falsy exports for validation while + * retaining named-export-only configuration modules. + */ +function selectConfigModuleValue(configModule: object): unknown { + const defaultExport = getOwnPropertyDescriptor(configModule, "default"); + if (defaultExport === undefined) return configModule; + + const value = getOwnPropertyDescriptor(defaultExport, "value"); + if (value === undefined) { + throw CONFIG_PARSE_ERROR.create({ + detail: "The configuration module default export is not a data binding", + }); + } + return value.value; +} + +function translateHostedConfigEvaluationError( + error: DeclarativeConfigEvaluationError, + configFile?: string, +): Error { + if (error.reason === "worker-aborted") return error; + + const context = { + ...(configFile === undefined ? {} : { configFile }), + code: error.code, + phase: error.phase, + reason: error.reason, + retryable: error.retryable, + location: error.location, + }; + + if (error.reason === "worker-protocol") { + return INITIALIZATION_ERROR.create({ + detail: "Hosted configuration evaluator returned an invalid response", + cause: error, + context, + }); + } + + if (error.code === "evaluator-unavailable" || error.code === "parser-unavailable") { + return SERVICE_OVERLOADED.create({ + detail: "Hosted configuration evaluation is temporarily unavailable", + cause: error, + context, + }); + } + + return CONFIG_PARSE_ERROR.create({ + detail: `Hosted configuration rejected (${error.code}: ${error.reason})`, + cause: error, + context, + }); +} + +function isPreservedConfigLoadError(error: unknown): boolean { + return error instanceof VeryfrontError; } async function loadConfigFromTempFile( @@ -330,9 +1508,12 @@ async function loadConfigFromTempFile( const tempFile = join(tempDir, `config${extension}`); try { - await fs.writeTextFile(tempFile, rewriteBareVeryfrontConfigImports(processedSource)); + await fs.writeTextFile( + tempFile, + await rewriteBareVeryfrontConfigImports(processedSource), + ); const configModule = await import(loadUrl(tempFile)); - return configModule.default || configModule; + return selectConfigModuleValue(configModule); } finally { await fs.remove(tempDir, { recursive: true }); } @@ -344,23 +1525,29 @@ async function loadConfigFromTempFile( * Config modules loaded through {@link loadConfigFromTempFile} execute from a * temp file, where bare specifiers have no resolver: Node has no node_modules * relative to the temp dir, and compiled Deno binaries have no import map for - * external dynamic imports. Every config helper the framework entrypoint - * exposes is a thin pure function, so a data: URL module is a faithful - * stand-in. Written with double quotes only — encodeURIComponent escapes - * them, keeping the URL safe inside either quote style of the rewritten - * import statement. + * external dynamic imports. The data URL delegates to the same helper + * implementations as the framework entrypoint, including its scoped + * environment reader. */ -const VERYFRONT_CONFIG_SHIM = [ - "export const defineConfig = (config) => config;", - "export const defineConfigWithEnv = (factory, envConfig) =>", - ' factory(envConfig?.nodeEnv ?? globalThis.Deno?.env?.get?.("NODE_ENV") ??', - ' globalThis.process?.env?.NODE_ENV ?? "production");', - "export const mergeConfigs = (...configs) => Object.assign({}, ...configs);", -].join("\n"); - -const VERYFRONT_CONFIG_SHIM_URL = `data:text/javascript,${ - encodeURIComponent(VERYFRONT_CONFIG_SHIM) -}`; +type DefaultModuleLexerModule = { + EsModuleLexer: new () => ModuleLexer; +}; + +let fallbackModuleLexerPromise: Promise | undefined; + +async function getConfigModuleLexer(): Promise { + const registered = tryResolveContract("ModuleLexer"); + if (registered) return registered; + + fallbackModuleLexerPromise ??= thenPromise( + importFirstPartyExtensionModule( + "ext-bundler-esbuild", + "@veryfront/ext-bundler-esbuild", + ), + ({ EsModuleLexer }) => new EsModuleLexer(), + ); + return await fallbackModuleLexerPromise; +} /** * Rewrite bare `veryfront` import specifiers to the inline config shim so @@ -371,12 +1558,20 @@ const VERYFRONT_CONFIG_SHIM_URL = `data:text/javascript,${ * * @internal exported for tests */ -export function rewriteBareVeryfrontConfigImports(source: string): string { - return source.replace( - /(\bfrom\s*|\bimport\s+)(["'])veryfront\2/g, - (_match, prefix: string, quote: string) => - `${prefix}${quote}${VERYFRONT_CONFIG_SHIM_URL}${quote}`, - ); +export async function rewriteBareVeryfrontConfigImports(source: string): Promise { + const lexer = await getConfigModuleLexer(); + await lexer.init?.(); + + const imports = lexer.parse(source); + let rewritten = source; + for (let index = imports.length - 1; index >= 0; index--) { + const specifier = imports[index]; + if (!specifier || specifier.d !== -1 || specifier.n !== "veryfront") continue; + rewritten = rewritten.slice(0, specifier.s) + + VERYFRONT_CONFIG_SHIM_URL + + rewritten.slice(specifier.e); + } + return rewritten; } /** @internal */ @@ -396,30 +1591,35 @@ export async function transpileConfigSourceForImport( } /** - * Load config from virtual filesystem. - * Uses esbuild to transpile TypeScript to JavaScript before importing. + * Load trusted executable config from a single-project virtual filesystem. + * + * Multi-tenant hosted callers never enter this path. */ -function loadConfigFromVirtualFS( +function loadTrustedConfigFromVirtualFS( configPath: string, cacheKey: string, adapter: RuntimeAdapter, + selectedContent?: string | Uint8Array, ): Promise { return withSpan( SpanNames.CONFIG_LOAD_PROJECT, async () => { logger.debug("Loading config from virtual filesystem (API)", { configPath }); - const content = await adapter.fs.readFile(configPath); - const source = typeof content === "string" ? content : new TextDecoder().decode(content); + const content = selectedContent ?? await adapter.fs.readFile(configPath); + const source = decodeTrustedConfigSource(content); logger.debug("Got config source from API", { configPath, sourceLength: source.length, - sourcePreview: source.slice(0, 200), }); const userConfig = await loadConfigFromTempFile( source, configPath, - (tempFile) => `file://${tempFile}?v=${Date.now()}`, + (tempFile) => { + const url = toFileUrl(tempFile); + url.searchParams.set("v", String(Date.now())); + return url.href; + }, ); logger.debug("Loaded config from virtual filesystem", { @@ -427,7 +1627,10 @@ function loadConfigFromVirtualFS( hasApp: !!(userConfig as Record)?.app, hasLayout: !!(userConfig as Record)?.layout, hasRouter: !!(userConfig as Record)?.router, - configKeys: Object.keys(userConfig as Record), + configKeys: userConfig !== null && + (typeof userConfig === "object" || typeof userConfig === "function") + ? ownKeys(userConfig) + : [], }); return validateAndMergeConfig(userConfig); @@ -436,10 +1639,61 @@ function loadConfigFromVirtualFS( ); } +function loadHostedConfigFromSource( + configPath: string, + configFile: DeclarativeConfigFileName, + baseCacheKey: string, + content: string | Uint8Array, + preparedContext: PreparedDeclarativeConfigContext, + signal: AbortSignal | undefined, + usePersistentCache: boolean, + revisionAtStart: number, +): Promise { + return withSpan( + SpanNames.CONFIG_LOAD_PROJECT, + async () => { + throwIfHostedConfigAborted(signal); + logger.debug("Loading hosted config through declarative worker", { configPath }); + const source = decodeConfigSource(content); + const payload = createPreparedDeclarativeConfigWorkerPayload( + source, + preparedContext, + configFile, + ); + const hostedCacheKey = await buildHostedConfigCacheKey( + baseCacheKey, + configPath, + source, + payload, + ); + throwIfHostedConfigAborted(signal); + + const cached = usePersistentCache ? configCacheByProject.get(hostedCacheKey) : undefined; + if (cached?.revision === revisionAtStart) { + return cached.config; + } + + const flight = getOrCreateHostedConfigFlight( + hostedCacheKey, + payload, + usePersistentCache, + revisionAtStart, + ); + return await waitForHostedConfigFlight(flight, signal); + }, + { + "config.path": configPath, + "config.project_dir": baseCacheKey, + "config.source": "hosted_declarative", + }, + ); +} + async function loadAndMergeConfig( configPath: string, cacheKey: string, adapter: RuntimeAdapter, + selectedVirtualContent?: string | Uint8Array, ): Promise { const isVirtualFS = isVirtualFilesystem(adapter.fs); logger.debug("loadAndMergeConfig called", { @@ -451,8 +1705,13 @@ async function loadAndMergeConfig( }); if (isVirtualFS) { - logger.debug("Using virtual filesystem (API) for config", { configPath }); - return loadConfigFromVirtualFS(configPath, cacheKey, adapter); + logger.debug("Using trusted single-project virtual filesystem for config", { configPath }); + return loadTrustedConfigFromVirtualFS( + configPath, + cacheKey, + adapter, + selectedVirtualContent, + ); } // Bun and compiled Deno binaries can't dynamically import TypeScript files directly. @@ -469,7 +1728,7 @@ async function loadAndMergeConfig( const userConfig = await loadConfigFromTempFile( source, configPath, - (tempFile) => `file://${tempFile}`, + (tempFile) => toFileUrl(tempFile).href, ); logger.debug("Successfully loaded config via temp file", { configPath, @@ -480,9 +1739,10 @@ async function loadAndMergeConfig( } const absolutePath = resolve(configPath); - const configUrl = `file://${absolutePath}?t=${Date.now()}-${crypto.randomUUID()}`; - const configModule = await import(configUrl); - return validateAndMergeConfig(configModule.default || configModule); + const configUrl = toFileUrl(absolutePath); + configUrl.searchParams.set("t", `${Date.now()}-${crypto.randomUUID()}`); + const configModule = await import(configUrl.href); + return validateAndMergeConfig(selectConfigModuleValue(configModule)); } /** @@ -504,6 +1764,62 @@ export interface GetConfigOptions { sourceContext?: VirtualConfigSourceContext; } +/** + * Internal server contract for untrusted hosted project configuration. + * + * This type is intentionally not re-exported from the public configuration + * barrels. Hosted callers must establish project, source, and environment + * identity before invoking the loader. + */ +export interface HostedConfigOptions { + readonly cacheKey: string; + readonly sourceContext: VirtualConfigSourceContext; + readonly preparedContext: PreparedDeclarativeConfigContext; + readonly signal?: AbortSignal; +} + +/** + * Authenticated source and environment binding for one hosted evaluation. + * + * A composition root derives this once from control-plane state and threads it + * to every consumer of that request's configuration. Nothing downstream may + * re-derive source or environment identity for itself. + * + * @internal + */ +export type PreparedHostedConfigContext = Pick< + HostedConfigOptions, + "sourceContext" | "preparedContext" +>; + +/** Exact declarative source selected by a trusted composition boundary. */ +export type HostedConfigSource = Readonly<{ + source: string; + fileName: DeclarativeConfigFileName; +}>; + +/** + * Explicit context for evaluating one hosted configuration source. + * + * Callers must derive both the source and environment from authenticated + * control-plane state. Passing `null` selects immutable framework defaults. + */ +export interface EvaluateHostedConfigSourceOptions { + /** Trusted immutable source identity, including project and release. */ + readonly cacheKey: string; + readonly source: HostedConfigSource | null; + readonly environmentName: string; + readonly environment: unknown; + readonly signal?: AbortSignal; +} + +interface InternalGetConfigOptions extends GetConfigOptions { + readonly hosted?: Readonly<{ + preparedContext: PreparedDeclarativeConfigContext; + signal?: AbortSignal; + }>; +} + function getVirtualConfigSourceContext(): VirtualConfigSourceContext | undefined { const source = getCurrentRequestContext(); if (!source) return undefined; @@ -546,6 +1862,18 @@ function normalizeVirtualConfigSource( }; } +function encodeVirtualConfigSourceIdentity( + context: VirtualConfigSourceContext, +): string { + if (!context.productionMode) { + return `branch:${frameConfigIdentityString(context.branch ?? "main")}`; + } + + return `production:${frameOptionalConfigIdentityString(context.releaseId)}${ + frameOptionalConfigIdentityString(context.environmentName) + }`; +} + function virtualConfigSourcesMatch( expected: NormalizedVirtualConfigSource, actual: NormalizedVirtualConfigSource, @@ -582,11 +1910,81 @@ function assertMatchingVirtualConfigSource( }); } -export function getConfig( +function assertMatchingHostedProjectIdentity( + cacheKey: string, + actual: ReturnType, +): void { + if (!actual) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: "Hosted multi-project config requires an active request context", + }); + } + + const canonicalProjectIdentity = actual.projectId ?? actual.projectSlug; + if (cacheKey === canonicalProjectIdentity) return; + + throw CACHE_INVARIANT_VIOLATION.create({ + detail: "Hosted config cache identity does not match the active project context", + }); +} + +function assertMatchingHostedEnvironmentIdentity( + sourceContext: VirtualConfigSourceContext, + payload: PreparedDeclarativeConfigWorkerPayload, +): void { + const actualEnvironmentName = payload.evaluationOptions.environmentName; + if (!sourceContext.productionMode) { + if (actualEnvironmentName === "preview") return; + } else if (sourceContext.environmentName) { + if (actualEnvironmentName === sourceContext.environmentName) return; + } else { + const environment = payload.evaluationOptions.environment; + if ( + actualEnvironmentName === "release" && + typeof environment === "object" && + environment !== null && + getPrototypeOf(environment) === null && + isFrozen(environment) && + ownKeys(environment).length === 0 + ) { + return; + } + } + + throw CACHE_INVARIANT_VIOLATION.create({ + detail: "Hosted config environment identity does not match its selected source", + }); +} + +function buildTrustedConfigIdentity( + effectiveCacheKey: string, + adapter: RuntimeAdapter, + isVirtualFS: boolean, + hasStableVirtualSourceIdentity: boolean, + ambientSourceContext: VirtualConfigSourceContext | undefined, +): string { + if (!isVirtualFS || hasStableVirtualSourceIdentity) return effectiveCacheKey; + + const filesystem = adapter.fs as object; + let filesystemId = weakMapGet(trustedVirtualFilesystemIds, filesystem); + if (filesystemId === undefined) { + filesystemId = nextTrustedVirtualFilesystemId; + nextTrustedVirtualFilesystemId += 1; + weakMapSet(trustedVirtualFilesystemIds, filesystem, filesystemId); + } + const sourceIdentity = ambientSourceContext + ? encodeVirtualConfigSourceIdentity(ambientSourceContext) + : "contextless"; + return `unqualified-vfs-v2:${frameConfigIdentityString(decimalIdentityNumber(filesystemId))}${ + frameConfigIdentityString(sourceIdentity) + }${frameConfigIdentityString(effectiveCacheKey)}`; +} + +function getConfigInternal( projectDir: string, adapter: RuntimeAdapter, - options?: GetConfigOptions, -): Promise { + options?: InternalGetConfigOptions, +): Promise { const getConfigStartTime = performance.now(); const cacheKeyForLog = options?.cacheKey || "unknown"; @@ -595,26 +1993,64 @@ export function getConfig( return withSpan( SpanNames.CONFIG_LOAD, async () => { + const revisionAtStart = cacheRevision; const isVirtualFS = isVirtualFilesystem(adapter.fs); - if (options?.sourceContext && (!isVirtualFS || !options.cacheKey)) { + const hosted = options?.hosted; + const hostedMultiProjectFilesystem = isHostedMultiProjectFilesystem(adapter); + if (hostedMultiProjectFilesystem && !hosted) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: + "Hosted multi-project config requires an authenticated declarative evaluation context", + }); + } + if (hosted && (!options?.cacheKey || !options.sourceContext)) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: "Hosted config requires canonical project and source identity", + }); + } + if (hosted) { + throwIfHostedConfigAborted(hosted.signal); + // Validate the opaque token before any project filesystem access. + const validationPayload = createPreparedDeclarativeConfigWorkerPayload( + "", + hosted.preparedContext, + ); + assertMatchingHostedEnvironmentIdentity(options!.sourceContext!, validationPayload); + } + + const hasQualifiedCacheIdentity = !!options?.cacheKey && (isVirtualFS || !!hosted); + if (options?.sourceContext && !hasQualifiedCacheIdentity) { throw CACHE_INVARIANT_VIOLATION.create({ detail: "Explicit config source requires a virtual filesystem and cacheKey", }); } const ambientSourceContext = isVirtualFS ? getVirtualConfigSourceContext() : undefined; - if (options?.sourceContext) { + if (options?.sourceContext && isVirtualFS) { assertMatchingVirtualConfigSource(options.sourceContext, ambientSourceContext); } - const sourceContext = isVirtualFS && options?.cacheKey + if (hostedMultiProjectFilesystem) { + assertMatchingHostedProjectIdentity(options!.cacheKey!, getCurrentRequestContext()); + } + const sourceContext = hasQualifiedCacheIdentity ? options.sourceContext ?? ambientSourceContext : undefined; - const usePersistentCache = !isVirtualFS || sourceContext?.productionMode === true; + const usePersistentCache = hosted + ? sourceContext?.productionMode === true + : !isVirtualFS || sourceContext?.productionMode === true; + const useVirtualCacheNamespace = !!hosted || (isVirtualFS && !!options?.cacheKey); const effectiveCacheKey = buildConfigCacheKey( - isVirtualFS && options?.cacheKey ? options.cacheKey : projectDir, - isVirtualFS && !!options?.cacheKey, + useVirtualCacheNamespace ? options!.cacheKey! : projectDir, + useVirtualCacheNamespace, sourceContext, ); + const trustedConfigIdentity = buildTrustedConfigIdentity( + effectiveCacheKey, + adapter, + isVirtualFS, + hasQualifiedCacheIdentity && sourceContext?.productionMode === true, + ambientSourceContext, + ); logger.debug("Cache key built", { effectiveCacheKey, @@ -624,8 +2060,12 @@ export function getConfig( usePersistentCache, }); - const cached = usePersistentCache ? configCacheByProject.get(effectiveCacheKey) : undefined; - if (cached?.revision === cacheRevision) { + // Hosted cache identity includes the exact source digest, so source must + // be read before the final cache lookup. + const cached = !hosted && usePersistentCache + ? configCacheByProject.get(effectiveCacheKey) + : undefined; + if (cached?.revision === revisionAtStart) { logger.debug("Cache HIT - using cached config", { cacheKey: effectiveCacheKey, isVirtualFS, @@ -633,7 +2073,7 @@ export function getConfig( hasLayout: !!(cached.config as Record).layout, duration: `${(performance.now() - getConfigStartTime).toFixed(2)}ms`, }); - return cached.config; + return createConfigLoadResult(cached.config, cached.provenance); } logger.debug("Cache MISS - loading config", { @@ -641,61 +2081,349 @@ export function getConfig( isVirtualFS, }); - // For virtual filesystem, config is at project root ("/"), not the local projectDir - const configBaseDir = isVirtualFS ? "/" : projectDir; - - for (const configFile of VERYFRONT_CONFIG_FILES) { - const configPath = join(configBaseDir, configFile); - const exists = await adapter.fs.exists(configPath); - logger.debug("Checking config file", { configPath, exists, isVirtualFS }); - if (!exists) continue; - - try { - const merged = await loadAndMergeConfig(configPath, effectiveCacheKey, adapter); - if (usePersistentCache) { - configCacheByProject.set(effectiveCacheKey, { - revision: cacheRevision, - config: merged, + const loadUncached = async (): Promise => { + // For virtual filesystem, config is at project root ("/"), not the local projectDir + const configBaseDir = isVirtualFS ? "/" : projectDir; + + if (hosted) { + let sourceReadLease: HostedConfigSourceReadLease; + try { + const sourceReadKey = buildHostedConfigSourceReadKey( + effectiveCacheKey, + configBaseDir, + adapter, + sourceContext!, + revisionAtStart, + ); + const sourceReadFlight = getOrCreateHostedConfigSourceReadFlight( + sourceReadKey, + () => readHostedConfigSource(adapter, configBaseDir), + ); + sourceReadLease = await waitForHostedConfigSourceReadFlight( + sourceReadFlight, + hosted.signal, + ); + } catch (error) { + if (error instanceof DeclarativeConfigEvaluationError) { + throw translateHostedConfigEvaluationError(error); + } + if (isPreservedConfigLoadError(error)) throw error; + throw CONFIG_PARSE_ERROR.create({ + detail: "Failed to select hosted configuration source", + cause: error, }); } - logger.debug("Successfully loaded config", { - configFile, - hasApp: !!merged.app, - hasLayout: !!(merged as Record).layout, - configKeys: Object.keys(merged), - }); - return merged; - } catch (error) { - if (isConfigError(error)) throw error; - logger.warn("Failed to load config file", { configFile }); - throw CONFIG_PARSE_ERROR.create({ - detail: `Failed to load ${configFile}`, - cause: error, - context: { configFile }, - }); + + try { + throwIfHostedConfigAborted(hosted.signal); + const selectedSource = sourceReadLease.selection; + if (selectedSource) { + const { configPath, configFile, source } = selectedSource; + try { + const merged = await loadHostedConfigFromSource( + configPath, + configFile, + effectiveCacheKey, + source, + hosted.preparedContext, + hosted.signal, + usePersistentCache, + revisionAtStart, + ); + const provenance = configFileProvenance(configFile); + logger.debug("Successfully loaded config", { + configFile, + hasApp: !!merged.app, + hasLayout: !!(merged as Record).layout, + configKeys: Object.keys(merged), + }); + return createConfigLoadResult(merged, provenance); + } catch (error) { + if (error instanceof DeclarativeConfigEvaluationError) { + throw translateHostedConfigEvaluationError(error, configFile); + } + if (isPreservedConfigLoadError(error)) throw error; + logger.warn("Failed to load config file", { configFile }); + throw CONFIG_PARSE_ERROR.create({ + detail: `Failed to load ${configFile}`, + cause: error, + context: { configFile }, + }); + } + } + + logger.debug("No config file found, using defaults", { + effectiveCacheKey, + projectDir, + isVirtualFS, + duration: `${(performance.now() - getConfigStartTime).toFixed(2)}ms`, + }); + throwIfHostedConfigAborted(hosted.signal); + const config = deepFreezeHostedConfig( + createFreshDefaults() as VeryfrontConfig, + ); + return createConfigLoadResult(config, defaultConfigProvenance()); + } finally { + sourceReadLease.release(); + } } - } - logger.debug("No config file found, using defaults", { - effectiveCacheKey, - projectDir, - isVirtualFS, - duration: `${(performance.now() - getConfigStartTime).toFixed(2)}ms`, - }); + for (const configFile of VERYFRONT_CONFIG_FILES) { + const configPath = join(configBaseDir, configFile); + let trustedVirtualContent: string | Uint8Array | undefined; + if (isVirtualFS) { + try { + trustedVirtualContent = await adapter.fs.readFile(configPath); + } catch (error) { + if (isNotFoundError(error)) { + logger.debug("Trusted virtual config candidate not found", { + configPath, + }); + continue; + } + throw error; + } + } else { + const exists = await adapter.fs.exists(configPath); + logger.debug("Checking config file", { configPath, exists, isVirtualFS }); + if (!exists) continue; + } + + try { + const merged = await loadAndMergeConfig( + configPath, + effectiveCacheKey, + adapter, + trustedVirtualContent, + ); + const provenance = configFileProvenance(configFile); + if (usePersistentCache && cacheRevision === revisionAtStart) { + configCacheByProject.set(effectiveCacheKey, { + revision: revisionAtStart, + config: merged, + provenance, + }); + } + logger.debug("Successfully loaded config", { + configFile, + hasApp: !!merged.app, + hasLayout: !!(merged as Record).layout, + configKeys: Object.keys(merged), + }); + return createConfigLoadResult(merged, provenance); + } catch (error) { + if (isPreservedConfigLoadError(error)) throw error; + logger.warn("Failed to load config file", { configFile }); + throw CONFIG_PARSE_ERROR.create({ + detail: `Failed to load ${configFile}`, + cause: error, + context: { configFile }, + }); + } + } - const defaultConfig = createFreshDefaults() as VeryfrontConfig; - if (usePersistentCache) { - configCacheByProject.set(effectiveCacheKey, { - revision: cacheRevision, - config: defaultConfig, + logger.debug("No config file found, using defaults", { + effectiveCacheKey, + projectDir, + isVirtualFS, + duration: `${(performance.now() - getConfigStartTime).toFixed(2)}ms`, }); - } - return defaultConfig; + + const config = createFreshDefaults() as VeryfrontConfig; + const provenance = defaultConfigProvenance(); + if (usePersistentCache && cacheRevision === revisionAtStart) { + configCacheByProject.set(effectiveCacheKey, { + revision: revisionAtStart, + config, + provenance, + }); + } + return createConfigLoadResult(config, provenance); + }; + + if (hosted) return await loadUncached(); + return await getOrCreateTrustedConfigFlight( + trustedConfigIdentity, + revisionAtStart, + loadUncached, + ); }, { "config.project_dir": projectDir, "config.cache_key": options?.cacheKey || "default" }, ); } +export function getConfig( + projectDir: string, + adapter: RuntimeAdapter, + options?: GetConfigOptions, +): Promise { + return thenPromise( + getConfigInternal(projectDir, adapter, options), + (result) => result.config, + ); +} + +/** + * Load trusted configuration together with the explicit source outcome. + * + * This is an internal composition boundary for callers that must distinguish + * an absent config file from a present file whose values happen to match the + * framework defaults. + * + * @internal + */ +export function getConfigWithProvenance( + projectDir: string, + adapter: RuntimeAdapter, + options?: GetConfigOptions, +): Promise { + return getConfigInternal(projectDir, adapter, options); +} + +/** + * Load an untrusted hosted project config through the bounded declarative + * evaluator. Server composition code must prepare the environment context + * from authenticated tenant data before calling this function. + * + * @internal + */ +export function getHostedConfig( + projectDir: string, + adapter: RuntimeAdapter, + options: HostedConfigOptions, +): Promise { + return thenPromise( + getConfigInternal(projectDir, adapter, { + cacheKey: options.cacheKey, + sourceContext: options.sourceContext, + hosted: { + preparedContext: options.preparedContext, + signal: options.signal, + }, + }), + (result) => result.config, + ); +} + +/** + * Evaluate an already-selected untrusted configuration source through the + * bounded declarative worker and return the same validated, merged, deeply + * frozen snapshot used by hosted request configuration. + * + * This seam exists for immutable-source jobs (for example release asset + * builds) whose source bytes are selected outside the runtime filesystem. It + * never imports or evaluates tenant JavaScript in the host realm. + * + * @internal + */ +export async function evaluateHostedConfigSource( + options: EvaluateHostedConfigSourceOptions, +): Promise { + throwIfHostedConfigAborted(options.signal); + if (options.source === null) { + return deepFreezeHostedConfig(validateAndMergeConfig({})); + } + + try { + const preparedContext = await prepareDeclarativeConfigContext({ + environmentName: options.environmentName, + environment: options.environment, + }); + return await loadHostedConfigFromSource( + options.source.fileName, + options.source.fileName, + options.cacheKey, + options.source.source, + preparedContext, + options.signal, + true, + cacheRevision, + ); + } catch (error) { + if (error instanceof DeclarativeConfigEvaluationError) { + throw translateHostedConfigEvaluationError(error, options.source.fileName); + } + if (isPreservedConfigLoadError(error)) throw error; + throw CONFIG_PARSE_ERROR.create({ + detail: `Failed to load ${options.source.fileName}`, + cause: error, + context: { configFile: options.source.fileName }, + }); + } +} + +/** @internal Test-only evaluator seam. Passing `undefined` restores production behavior. */ +export function __setHostedConfigEvaluatorForTests( + evaluator?: HostedConfigEvaluator, +): void { + mapForEach(hostedConfigFlights, (flight) => { + abortController(flight.controller); + }); + mapClear(hostedConfigFlights); + hostedConfigEvaluator = evaluator ?? evaluatePreparedDeclarativeConfigInWorker; +} + +/** + * @internal Test-only seam for the captured Promise observer. This keeps + * adversarial constructor/species coverage independent of unrelated awaits in + * tracing and filesystem dependencies. + */ +export function __observePromiseForTests(promise: Promise): Promise { + return thenPromise(promise, (value) => value); +} + +/** + * @internal Test-only aggregate source-read admission state. Active reads + * remain counted after their final waiter aborts until the adapter settles. + */ +export function __getHostedConfigSourceReadStateForTests(): Readonly<{ + active: number; + queued: number; + flights: number; + waiters: number; + maxActive: number; + maxQueued: number; +}> { + let waiters = 0; + mapForEach(hostedConfigSourceReadFlights, (flight) => { + waiters += flight.waiterCount; + }); + return freezeObject({ + active: activeHostedConfigSourceReads, + queued: queuedHostedConfigSourceReads, + flights: mapSize(hostedConfigSourceReadFlights), + waiters, + maxActive: MAX_ACTIVE_HOSTED_CONFIG_SOURCE_READS, + maxQueued: MAX_QUEUED_HOSTED_CONFIG_SOURCE_READS, + }); +} + +/** @internal Test-only aggregate state; does not expose project or source identities. */ +export function __getHostedConfigFlightStateForTests(): Readonly<{ + flights: number; + waiters: number; +}> { + let waiters = 0; + mapForEach(hostedConfigFlights, (flight) => { + waiters += flight.waiterCount; + }); + return freezeObject({ + flights: mapSize(hostedConfigFlights), + waiters, + }); +} + +/** @internal Test-only aggregate state; does not expose config identities. */ +export function __getTrustedConfigFlightStateForTests(): Readonly<{ + flights: number; + maxFlights: number; +}> { + return freezeObject({ + flights: mapSize(trustedConfigFlights), + maxFlights: MAX_TRUSTED_CONFIG_FLIGHTS, + }); +} + export function clearConfigCache(): void { configCacheByProject.clear(); cacheRevision++; diff --git a/src/config/network-defaults.test.ts b/src/config/network-defaults.test.ts index 0d64e05ae9..a0da9770ff 100644 --- a/src/config/network-defaults.test.ts +++ b/src/config/network-defaults.test.ts @@ -4,9 +4,11 @@ import { assertEquals } from "#veryfront/testing/assert"; import { buildIpv4Url, buildLocalhostUrl, + DEV_LOCALHOST_CSP, + DEV_LOCALHOST_ORIGINS, HTTP_DEFAULTS, LOCALHOST, - REDIS_DEFAULTS, + LOCALHOST_URLS, } from "./network-defaults.ts"; describe("network-defaults", () => { @@ -20,8 +22,12 @@ describe("network-defaults", () => { assertEquals(HTTP_DEFAULTS.PORT, 3000); }); - it("REDIS_DEFAULTS should have correct default URL", () => { - assertEquals(REDIS_DEFAULTS.URL, "redis://127.0.0.1:6379"); + it("keeps exported network defaults immutable at runtime", () => { + assertEquals(Object.isFrozen(LOCALHOST), true); + assertEquals(Object.isFrozen(HTTP_DEFAULTS), true); + assertEquals(Object.isFrozen(DEV_LOCALHOST_ORIGINS), true); + assertEquals(Object.isFrozen(DEV_LOCALHOST_CSP), true); + assertEquals(Object.isFrozen(LOCALHOST_URLS), true); }); describe("buildLocalhostUrl", () => { diff --git a/src/config/network-defaults.ts b/src/config/network-defaults.ts index b64a308f35..1eb1694f42 100644 --- a/src/config/network-defaults.ts +++ b/src/config/network-defaults.ts @@ -1,36 +1,38 @@ import { LOCALHOST } from "#veryfront/platform/compat/constants.ts"; export { LOCALHOST }; -export const HTTP_DEFAULTS = { - PORT: 3000, - HOST: "localhost", - PROD_HOST: "0.0.0.0", -} as const; - -export const REDIS_DEFAULTS = { - URL: "redis://127.0.0.1:6379", - PORT: 6379, - HOST: "127.0.0.1", -} as const; - -export const DEV_LOCALHOST_ORIGINS = [ - "http://localhost", - "http://127.0.0.1", - "https://localhost", - "https://127.0.0.1", -] as const; - -export const DEV_LOCALHOST_CSP = { - WS: "ws://localhost:* wss://localhost:*", - HTTP: "http://localhost", -} as const; - -export const LOCALHOST_URLS = { - HTTP: "http://localhost", - HTTPS: "https://localhost", - HTTP_IPV4: "http://127.0.0.1", - HTTPS_IPV4: "https://127.0.0.1", -} as const; +export const HTTP_DEFAULTS = Object.freeze( + { + PORT: 3000, + HOST: "localhost", + PROD_HOST: "0.0.0.0", + } as const, +); + +export const DEV_LOCALHOST_ORIGINS = Object.freeze( + [ + "http://localhost", + "http://127.0.0.1", + "https://localhost", + "https://127.0.0.1", + ] as const, +); + +export const DEV_LOCALHOST_CSP = Object.freeze( + { + WS: "ws://localhost:* wss://localhost:*", + HTTP: "http://localhost", + } as const, +); + +export const LOCALHOST_URLS = Object.freeze( + { + HTTP: "http://localhost", + HTTPS: "https://localhost", + HTTP_IPV4: "http://127.0.0.1", + HTTPS_IPV4: "https://127.0.0.1", + } as const, +); function buildUrl( host: string, diff --git a/src/config/public.ts b/src/config/public.ts deleted file mode 100644 index 58d7942479..0000000000 --- a/src/config/public.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Public configuration API for veryfront/config - * - * User-facing exports only. Internal infrastructure (RuntimeConfig, network defaults, - * histogram boundaries, etc.) is available via #veryfront/config for framework internals. - */ - -export { clearConfigCache, getConfig, type GetConfigOptions } from "./loader.ts"; - -export { defineConfig, defineConfigWithEnv, mergeConfigs } from "./define-config.ts"; - -export { getApiTokenEnv, isCiEnv, isDenoTestingEnv } from "./env.ts"; - -export { - findUnknownTopLevelKeys, - validateVeryfrontConfig, - type VeryfrontConfig, - type VeryfrontConfigInput, - veryfrontConfigSchema, -} from "./schemas/index.ts"; - -export { DEFAULT_PORT } from "./defaults.ts"; diff --git a/src/config/runtime-config.test.ts b/src/config/runtime-config.test.ts index 86c038df99..2406bb7965 100644 --- a/src/config/runtime-config.test.ts +++ b/src/config/runtime-config.test.ts @@ -35,9 +35,20 @@ describe("RuntimeConfig", () => { expect(DEFAULT_CONFIG.description).toBe("Built with Veryfront"); expect(DEFAULT_CONFIG.experimental?.esmLayouts).toBe(true); expect(DEFAULT_CONFIG.build?.outDir).toBe("dist"); - expect(DEFAULT_CONFIG.dev?.port).toBe(3001); + expect(DEFAULT_CONFIG.dev?.port).toBe(3000); expect(DEFAULT_CONFIG.cache?.dir).toBe(".veryfront"); }); + + it("is deeply frozen so consumers cannot corrupt future configs", () => { + expect(Object.isFrozen(DEFAULT_CONFIG)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.experimental)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.theme)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.theme?.colors)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.build)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.cache)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.cache?.render)).toBe(true); + expect(Object.isFrozen(DEFAULT_CONFIG.dev)).toBe(true); + }); }); describe("createRuntimeConfig", () => { @@ -46,10 +57,21 @@ describe("RuntimeConfig", () => { const config = createRuntimeConfig({}, env); expect(config.title).toBe("Veryfront App"); + expect(config.build?.esbuild).toBeUndefined(); expect(config.runtime).toBeDefined(); expect(config.runtime.env).toBe(env); }); + it("keeps standalone config out of the process singleton", () => { + const config = createRuntimeConfig( + { title: "Request-scoped value" }, + createTestEnvironmentConfig(), + ); + + expect(config.title).toBe("Request-scoped value"); + expect(isRuntimeConfigInitialized()).toBe(false); + }); + it("merges file config with defaults", () => { const env = createTestEnvironmentConfig(); const config = createRuntimeConfig( @@ -62,6 +84,63 @@ describe("RuntimeConfig", () => { expect(config.description).toBe("Built with Veryfront"); }); + it("deep-merges nested file config with runtime defaults", () => { + const config = createRuntimeConfig( + { + theme: { colors: { secondary: "#000000" } }, + build: { trailingSlash: true }, + cache: { dir: "/tmp/runtime-cache" }, + dev: { open: true }, + }, + createTestEnvironmentConfig(), + ); + + expect(config.theme?.colors?.primary).toBe("#3B82F6"); + expect(config.theme?.colors?.secondary).toBe("#000000"); + expect(config.build?.outDir).toBe("dist"); + expect(config.build?.trailingSlash).toBe(true); + expect(config.cache?.dir).toBe("/tmp/runtime-cache"); + expect(config.cache?.render?.type).toBe("memory"); + expect(config.dev?.host).toBe("localhost"); + expect(config.dev?.open).toBe(true); + }); + + it("preserves explicitly configured esbuild options", () => { + const config = createRuntimeConfig( + { + build: { + esbuild: { + wasmURL: "https://cdn.example.com/esbuild.wasm", + worker: false, + }, + }, + }, + createTestEnvironmentConfig(), + ); + + expect(config.build?.esbuild).toEqual({ + wasmURL: "https://cdn.example.com/esbuild.wasm", + worker: false, + }); + }); + + it("creates independent nested defaults for every runtime config", () => { + const first = createRuntimeConfig({}, createTestEnvironmentConfig()); + const second = createRuntimeConfig({}, createTestEnvironmentConfig()); + + expect(first.theme).not.toBe(second.theme); + expect(first.theme?.colors).not.toBe(second.theme?.colors); + expect(first.build).not.toBe(second.build); + expect(first.cache?.render).not.toBe(second.cache?.render); + expect(first.dev).not.toBe(second.dev); + + if (!first.theme?.colors) throw new Error("Expected runtime theme defaults"); + first.theme.colors.primary = "#ffffff"; + + expect(second.theme?.colors?.primary).toBe("#3B82F6"); + expect(DEFAULT_CONFIG.theme?.colors?.primary).toBe("#3B82F6"); + }); + it("computes runtime flags correctly", () => { const prodConfig = createRuntimeConfig( {}, @@ -136,6 +215,15 @@ describe("RuntimeConfig", () => { expect(config.dev?.port).toBe(9000); }); + it("preserves a file port when PORT was not configured", () => { + const config = createRuntimeConfig( + { dev: { port: 4321 } }, + createTestEnvironmentConfig(), + ); + + expect(config.dev?.port).toBe(4321); + }); + it("ignores project-file observability routing in shared proxy mode", () => { const config = createRuntimeConfig( { @@ -192,6 +280,27 @@ describe("RuntimeConfig", () => { ); expect(config.observability?.tracing?.serviceName).toBe("veryfront-ops-agent"); }); + + it("preserves non-routing observability config outside shared proxy mode", () => { + const config = createRuntimeConfig( + { + observability: { + logging: { + file: { + enabled: true, + path: "/tmp/veryfront.log", + format: "json", + }, + }, + }, + }, + createTestEnvironmentConfig({ proxyMode: false }), + ); + + expect(config.observability?.logging?.file?.enabled).toBe(true); + expect(config.observability?.logging?.file?.path).toBe("/tmp/veryfront.log"); + expect(config.observability?.logging?.file?.format).toBe("json"); + }); }); describe("initRuntimeConfig", () => { diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 806571da05..3042ee0747 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -1,8 +1,14 @@ /**** * Runtime Configuration * - * Combines file-based config (veryfront.config.ts) with runtime environment. - * This is the primary config type that should be used throughout the application. + * Opt-in helpers for combining a caller-supplied `VeryfrontConfig` with a + * process environment snapshot. + * + * The server bootstrap and hosted project loader do not publish project + * configuration to this process-wide singleton. Hosted request code must keep + * tenant configuration request-scoped; placing it here would leak state across + * tenants. Without an explicit caller-supplied config, the singleton contains + * only framework defaults and host environment values. * * @module */ @@ -10,9 +16,8 @@ import type { VeryfrontConfig } from "./schemas/index.ts"; import type { EnvironmentConfig } from "./environment-config.ts"; import { createTestEnvironmentConfig, getEnvironmentConfig } from "./environment-config.ts"; - -/** Maximum entries in the default render cache */ -const DEFAULT_RENDER_CACHE_MAX_ENTRIES = 500; +import { DEFAULT_RENDER_CACHE_MAX_ENTRIES } from "./defaults.ts"; +import { DEFAULT_DEV_SERVER_PORT } from "#veryfront/utils/constants/network.ts"; /** * Runtime-specific configuration derived from environment. @@ -38,8 +43,8 @@ export interface RuntimeInfo { } /** - * Full runtime configuration. - * Combines user config file with runtime environment. + * A caller-supplied project configuration combined with one environment + * snapshot. Creating this value does not initialize the process singleton. */ export interface RuntimeConfig extends VeryfrontConfig { /** @@ -53,7 +58,21 @@ export interface RuntimeConfig extends VeryfrontConfig { * Default configuration values. * Used when no config file is found. */ -export const DEFAULT_CONFIG: Partial = { +function deepFreezeDefaults(value: T, seen = new WeakSet()): T { + if (value === null || typeof value !== "object") return value; + + const object = value as object; + if (seen.has(object)) return value; + seen.add(object); + + for (const child of Object.values(value as Record)) { + deepFreezeDefaults(child, seen); + } + + return Object.freeze(value); +} + +export const DEFAULT_CONFIG: Partial = deepFreezeDefaults({ title: "Veryfront App", description: "Built with Veryfront", experimental: { @@ -77,11 +96,11 @@ export const DEFAULT_CONFIG: Partial = { }, }, dev: { - port: 3001, + port: DEFAULT_DEV_SERVER_PORT, host: "localhost", open: false, }, -}; +}); function createRuntimeInfo(env: EnvironmentConfig): RuntimeInfo { return { @@ -116,6 +135,7 @@ function mergeObservabilityConfig( } return { + ...fileConfig.observability, tracing: { ...fileConfig.observability?.tracing, enabled: env.otelEnabled || fileConfig.observability?.tracing?.enabled, @@ -144,26 +164,65 @@ function mergeConfigWithEnv(fileConfig: VeryfrontConfig, env: EnvironmentConfig) cache: { ...fileConfig.cache, dir: env.cacheDir || fileConfig.cache?.dir, - render: { - ...fileConfig.cache?.render, - redisUrl: env.redisUrl || fileConfig.cache?.render?.redisUrl, - }, }, dev: { ...fileConfig.dev, - port: env.port || fileConfig.dev?.port, + port: env.portSource === "default" ? fileConfig.dev?.port : env.port, }, observability: mergeObservabilityConfig(fileConfig, env), }; } +function mergeConfigWithDefaults(fileConfig: VeryfrontConfig): VeryfrontConfig { + const mergedBuild: NonNullable = { + ...DEFAULT_CONFIG.build, + ...fileConfig.build, + }; + if (DEFAULT_CONFIG.build?.esbuild || fileConfig.build?.esbuild) { + mergedBuild.esbuild = { + ...DEFAULT_CONFIG.build?.esbuild, + ...fileConfig.build?.esbuild, + }; + } + + return { + ...DEFAULT_CONFIG, + ...fileConfig, + experimental: { + ...DEFAULT_CONFIG.experimental, + ...fileConfig.experimental, + }, + theme: { + ...DEFAULT_CONFIG.theme, + ...fileConfig.theme, + colors: { + ...DEFAULT_CONFIG.theme?.colors, + ...fileConfig.theme?.colors, + }, + }, + build: mergedBuild, + cache: { + ...DEFAULT_CONFIG.cache, + ...fileConfig.cache, + render: { + ...DEFAULT_CONFIG.cache?.render, + ...fileConfig.cache?.render, + }, + }, + dev: { + ...DEFAULT_CONFIG.dev, + ...fileConfig.dev, + }, + }; +} + export function createRuntimeConfig( fileConfig: VeryfrontConfig = {}, env: EnvironmentConfig = getEnvironmentConfig(), ): RuntimeConfig { - const mergedConfig = mergeConfigWithEnv({ ...DEFAULT_CONFIG, ...fileConfig }, env); + const mergedConfig = mergeConfigWithEnv(mergeConfigWithDefaults(fileConfig), env); return { ...mergedConfig, @@ -177,6 +236,12 @@ export function createRuntimeConfig( let runtimeConfig: RuntimeConfig | null = null; +/** + * Explicitly initialize the process-local singleton. + * + * This utility is intended for trusted single-tenant startup and tooling. The + * hosted server does not call it with tenant configuration. + */ export function initRuntimeConfig(fileConfig: VeryfrontConfig = {}): RuntimeConfig { if (runtimeConfig) return runtimeConfig; @@ -184,6 +249,12 @@ export function initRuntimeConfig(fileConfig: VeryfrontConfig = {}): RuntimeConf return runtimeConfig; } +/** + * Read the opt-in process singleton, lazily creating defaults plus host + * environment values when no caller initialized it. + * + * This does not discover or load `veryfront.config.*`. + */ export function getRuntimeConfig(): RuntimeConfig { return runtimeConfig ?? initRuntimeConfig(); } @@ -192,6 +263,11 @@ export function isRuntimeConfigInitialized(): boolean { return runtimeConfig !== null; } +/** + * Replace the trusted process-local singleton. + * + * Never pass hosted request or tenant configuration to this function. + */ export function updateRuntimeConfig(fileConfig: VeryfrontConfig): RuntimeConfig { runtimeConfig = createRuntimeConfig(fileConfig); return runtimeConfig; @@ -201,7 +277,10 @@ export function updateRuntimeConfig(fileConfig: VeryfrontConfig): RuntimeConfig // GlobalThis Bridge // ============================================================================ // Register accessors on globalThis so bottom-layer code (platform/) can reach -// runtime config without importing from config/ (which would violate layer rules). +// an explicitly initialized process config without importing from config/ +// (which would violate layer rules). The platform resolver checks +// `isRuntimeConfigInitialized` first, so importing this module alone does not +// make the singleton authoritative. (globalThis as Record).__vfGetRuntimeConfig = getRuntimeConfig; (globalThis as Record).__vfIsRuntimeConfigInitialized = isRuntimeConfigInitialized; @@ -217,9 +296,7 @@ export function createTestConfig( const { runtime: runtimeOverrides, ...configOverrides } = overrides; const testEnv = createTestEnvironmentConfig(runtimeOverrides?.env); - const fileConfig = { ...DEFAULT_CONFIG, ...configOverrides }; - - return createRuntimeConfig(fileConfig, testEnv); + return createRuntimeConfig(configOverrides, testEnv); } export function _setRuntimeConfigForTesting( diff --git a/src/config/schemas/config.schema.test.ts b/src/config/schemas/config.schema.test.ts index f8b3e77a57..de782ac176 100644 --- a/src/config/schemas/config.schema.test.ts +++ b/src/config/schemas/config.schema.test.ts @@ -1,18 +1,36 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertStringIncludes, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { MAX_CACHE_TTL_MILLISECONDS } from "#veryfront/cache/backends/ttl.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; +import { MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS } from "#veryfront/integrations/limits.ts"; +import { + MAX_CORS_ORIGIN_COUNT, + MAX_CORS_ORIGIN_LENGTH, + MAX_CORS_TOKEN_COUNT, + MAX_CORS_TOKEN_LENGTH, +} from "#veryfront/utils/cors-policy-limits.ts"; +import { + MAX_REMOTE_HOST_COUNT, + MAX_REMOTE_HOST_URL_LENGTH, +} from "#veryfront/utils/remote-host-policy-limits.ts"; +import { + MAX_FILE_LOG_FILES, + MAX_GITHUB_FILESYSTEM_ATTEMPTS, + MAX_VERYFRONT_FILESYSTEM_RETRIES, +} from "#veryfront/utils/config-resource-limits.ts"; import { CSS_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; -import { findUnknownTopLevelKeys, validateVeryfrontConfig } from "./config.schema.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; +import { validateVeryfrontConfig } from "./config.schema.ts"; describe("configSchema", () => { - it("validates valid config and finds unknown keys", () => { + it("validates valid config", () => { const cfg = validateVeryfrontConfig({ router: "app", security: { cors: true, remoteHosts: ["https://esm.sh"] }, }); assertEquals(cfg.router, "app"); - assertEquals(findUnknownTopLevelKeys({ foo: 1, router: "pages" }), ["foo"]); }); it("keeps CSS asset-pipeline schema constraints aligned with runtime", () => { @@ -21,6 +39,7 @@ describe("configSchema", () => { enabled: true, projectDir, inputFiles: ["styles/main.css"], + browsers: ["defaults", "not IE 11"], purge: true, purgeContent: ["app/**/*.tsx"], purgeSafelist: ["dynamic"], @@ -33,8 +52,10 @@ describe("configSchema", () => { for ( const invalid of [ { projectDir: "relative/project" }, - { autoprefixer: true }, - { browsers: ["defaults"] }, + { browsers: [] }, + { criticalCSS: true }, + { purge: true, sourceMap: true }, + { purge: true, purgeContent: [] }, { purgeSafelist: Array.from( { length: CSS_OPTIMIZATION.MAX_PURGE_SAFELIST_ENTRIES + 1 }, @@ -51,6 +72,393 @@ describe("configSchema", () => { } }); + it("keeps image asset-pipeline schema constraints aligned with runtime", () => { + const images = { + projectDir: Deno.cwd(), + formats: ["webp", "png"], + sizes: [320, 640], + quality: 85, + inputDir: "public", + outputDir: ".veryfront/images", + preserveOriginal: true, + }; + assertEquals( + validateVeryfrontConfig({ assetPipeline: { images } }).assetPipeline + ?.images, + images, + ); + + for ( + const invalid of [ + { projectDir: "relative/project" }, + { formats: [] }, + { formats: ["webp", "webp"] }, + { sizes: [320, 320] }, + { inputDir: "" }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ assetPipeline: { images: invalid } }), + Error, + "assetPipeline.images", + ); + } + }); + + it("rejects unknown top-level keys through the public validator", () => { + const error = assertThrows(() => + validateVeryfrontConfig({ + title: "Typo", + buid: { outDir: "dist" }, + }) + ); + + assertEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, "config-validation-failed"); + assertEquals( + (error as Error).message, + 'Invalid veryfront.config at : Unrecognized key: "buid".', + ); + assertEquals((error as VeryfrontError).context, { + field: "", + expected: 'Unrecognized key: "buid"', + }); + }); + + it("rejects unknown keys in closed nested configuration objects", () => { + const github = { token: "token", owner: "owner", repo: "repo" }; + for ( + const [config, path, key] of [ + [{ dev: { potr: 4444 } }, "dev", "potr"], + [{ build: { outDri: "dist" } }, "build", "outDri"], + [ + { fs: { type: "github", github: { ...github, cach: {} } } }, + "fs.github", + "cach", + ], + [ + { ai: { tools: { discovery: { pahts: [] } } } }, + "ai.tools.discovery", + "pahts", + ], + ] as const + ) { + const error = assertThrows( + () => validateVeryfrontConfig(config), + Error, + `Invalid veryfront.config at ${path}:`, + ); + assertEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, "config-validation-failed"); + assertEquals((error as VeryfrontError).context, { + field: path, + expected: `Unrecognized key: "${key}"`, + }); + } + }); + + it("rejects unsafe and unbounded AI discovery roots", () => { + for ( + const path of [ + "", + ".", + "../tools", + "tools/../outside", + "tools//nested", + "/absolute/tools", + String.raw`C:\absolute\tools`, + String.raw`C:relative\tools`, + "file:///absolute/tools", + "FILE:///absolute/tools", + ] + ) { + assertThrows( + () => + validateVeryfrontConfig({ + ai: { + tools: { + discovery: { paths: [path] }, + }, + }, + }), + Error, + "ai.tools.discovery.paths.0", + ); + } + }); + + it("preserves values in intentional dynamic extension points", () => { + const config = validateVeryfrontConfig({ + theme: { colors: { brand: "#123456" } }, + resolve: { + importMap: { + imports: { package: "https://example.com/package.ts" }, + scopes: { "/feature/": { package: "https://example.com/scoped.ts" } }, + }, + }, + ai: { + providers: { + custom: { + apiKey: "key", + providerSpecificOption: { mode: "strict" }, + }, + }, + }, + }); + + assertEquals(config.theme?.colors?.brand, "#123456"); + assertEquals( + config.resolve?.importMap?.scopes?.["/feature/"]?.package, + "https://example.com/scoped.ts", + ); + assertEquals( + config.ai?.providers?.custom?.providerSpecificOption, + { mode: "strict" }, + ); + }); + + it("supports provider-neutral styles while retaining Tailwind authoring", () => { + const config = validateVeryfrontConfig({ + styles: { stylesheet: "styles/global.css" }, + tailwind: { stylesheet: "globals.css", plugins: ["typography"] }, + }); + assertEquals(config.styles?.stylesheet, "styles/global.css"); + assertEquals(config.tailwind?.stylesheet, "globals.css"); + assertEquals(config.tailwind?.plugins, ["typography"]); + assertThrows( + () => + validateVeryfrontConfig({ + styles: { stylesheet: "globals.css", plugins: ["typography"] }, + } as never), + Error, + "plugins", + ); + for ( + const stylesheet of [ + "/globals.css", + "../globals.css", + "styles/../globals.css", + "styles\\globals.css", + "styles//globals.css", + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ styles: { stylesheet } }), + Error, + "canonical project-relative stylesheet path", + ); + } + }); + + it("rejects empty configured authentication credentials", () => { + for ( + const auth of [ + { basic: { username: "", password: "password" } }, + { basic: { username: "user", password: "" } }, + { bearer: { token: "" } }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ security: { auth } }), + Error, + "Invalid veryfront.config at security.auth", + ); + } + }); + + it("rejects ambiguous authentication modes", () => { + assertThrows( + () => + validateVeryfrontConfig({ + security: { + auth: { + basic: { username: "user", password: "password" }, + bearer: { token: "token" }, + }, + }, + }), + Error, + "Configure either basic or bearer authentication, not both", + ); + }); + + it("rejects filesystem options that do not match the selected backend", () => { + const github = { token: "token", owner: "owner", repo: "repo" }; + const veryfront = { apiBaseUrl: "https://api.veryfront.com" }; + + for ( + const fs of [ + { github }, + { type: "local", github }, + { type: "github" }, + { type: "github", github, local: { baseDir: "/tmp" } }, + { type: "veryfront-api" }, + { type: "veryfront-api", veryfront, memory: { files: {} } }, + { type: "memory", veryfront }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ fs }), + Error, + "Filesystem options must belong to the selected backend type", + ); + } + + assertEquals( + validateVeryfrontConfig({ fs: { type: "github", github } }).fs?.type, + "github", + ); + assertEquals( + validateVeryfrontConfig({ fs: { type: "veryfront-api", veryfront } }).fs?.type, + "veryfront-api", + ); + }); + + it("bounds filesystem retry delays to the portable timer domain", () => { + const validRetry = { + maxRetries: 3, + initialDelay: 0, + maxDelay: MAX_TIMER_DELAY_MS, + }; + assertEquals( + validateVeryfrontConfig({ + fs: { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + retry: validRetry, + }, + }, + }).fs?.veryfront?.retry, + validRetry, + ); + assertEquals( + validateVeryfrontConfig({ + fs: { + type: "github", + github: { + token: "token", + owner: "owner", + repo: "repo", + retry: validRetry, + }, + }, + }).fs?.github?.retry, + validRetry, + ); + + for ( + const retry of [ + { initialDelay: MAX_TIMER_DELAY_MS + 1 }, + { maxDelay: MAX_TIMER_DELAY_MS + 1 }, + { initialDelay: 1_000, maxDelay: 500 }, + ] + ) { + for ( + const fs of [ + { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + retry, + }, + }, + { + type: "github", + github: { + token: "token", + owner: "owner", + repo: "repo", + retry, + }, + }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ fs }), + Error, + "Invalid veryfront.config at fs", + ); + } + } + }); + + it("bounds each filesystem backend without changing its retry-count contract", () => { + const accepted = [ + { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + retry: { maxRetries: MAX_VERYFRONT_FILESYSTEM_RETRIES }, + }, + }, + { + type: "github", + github: { + token: "token", + owner: "owner", + repo: "repo", + retry: { maxRetries: MAX_GITHUB_FILESYSTEM_ATTEMPTS }, + }, + }, + ]; + for (const fs of accepted) { + assertEquals(validateVeryfrontConfig({ fs }).fs?.type, fs.type); + } + + const rejected = [ + { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + retry: { maxRetries: MAX_VERYFRONT_FILESYSTEM_RETRIES + 1 }, + }, + }, + { + type: "github", + github: { + token: "token", + owner: "owner", + repo: "repo", + retry: { maxRetries: MAX_GITHUB_FILESYSTEM_ATTEMPTS + 1 }, + }, + }, + ]; + for (const fs of rejected) { + assertThrows( + () => validateVeryfrontConfig({ fs }), + Error, + "Invalid veryfront.config at fs", + ); + } + }); + + it("bounds file log retention before rotation work is scheduled", () => { + assertEquals( + validateVeryfrontConfig({ + observability: { + logging: { + file: { maxFiles: MAX_FILE_LOG_FILES }, + }, + }, + }).observability?.logging?.file?.maxFiles, + MAX_FILE_LOG_FILES, + ); + + assertThrows( + () => + validateVeryfrontConfig({ + observability: { + logging: { + file: { maxFiles: MAX_FILE_LOG_FILES + 1 }, + }, + }, + }), + Error, + "Invalid veryfront.config at observability.logging.file.maxFiles", + ); + }); + it("accepts build.ssg as a boolean", () => { const enabled = validateVeryfrontConfig({ build: { ssg: true } }); assertEquals(enabled.build?.ssg, true); @@ -70,14 +478,434 @@ describe("configSchema", () => { ); }); + it("returns registered validation errors without retaining the full config", () => { + const input = { + dev: { port: "invalid" }, + security: { auth: { bearer: { token: "secret-token" } } }, + }; + + const error = assertThrows(() => validateVeryfrontConfig(input)); + + assertEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, "config-validation-failed"); + assertEquals((error as VeryfrontError).context, { + field: "dev.port", + expected: "Invalid input: expected number, received string", + }); + }); + + it("bounds every configured server port", () => { + for ( + const input of [ + { dev: { port: 0 } }, + { dev: { port: 65536 } }, + { dev: { hmrPort: 1.5 } }, + { dev: { hmrPort: 65536 } }, + { ai: { mcp: { port: 0 } } }, + { ai: { mcp: { port: 65536 } } }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig(input), + Error, + "Invalid veryfront.config at", + ); + } + + const config = validateVeryfrontConfig({ + dev: { port: 1, hmrPort: 65535 }, + ai: { mcp: { port: 3001 } }, + }); + assertEquals(config.dev?.port, 1); + assertEquals(config.dev?.hmrPort, 65535); + assertEquals(config.ai?.mcp?.port, 3001); + }); + + it("accepts remote host policies at their exact count and URL length limits", () => { + const prefix = "https://example.com/"; + const exactLengthUrl = prefix + "a".repeat(MAX_REMOTE_HOST_URL_LENGTH - prefix.length); + const remoteHosts = Array.from( + { length: MAX_REMOTE_HOST_COUNT }, + (_, index) => `https://host-${index}.example`, + ); + remoteHosts[0] = exactLengthUrl; + + const config = validateVeryfrontConfig({ security: { remoteHosts } }); + + assertEquals(config.security?.remoteHosts?.length, MAX_REMOTE_HOST_COUNT); + assertEquals(config.security?.remoteHosts?.[0], exactLengthUrl); + assertEquals( + validateVeryfrontConfig({ security: { remoteHosts: [] } }).security?.remoteHosts, + [], + ); + }); + + it("rejects remote host policies above their count or URL length limits", () => { + const prefix = "https://example.com/"; + const overLengthUrl = prefix + + "a".repeat(MAX_REMOTE_HOST_URL_LENGTH + 1 - prefix.length); + const overCountHosts = Array.from( + { length: MAX_REMOTE_HOST_COUNT + 1 }, + (_, index) => `https://host-${index}.example`, + ); + + for (const remoteHosts of [overCountHosts, [overLengthUrl]]) { + assertThrows( + () => validateVeryfrontConfig({ security: { remoteHosts } }), + Error, + "Invalid veryfront.config at security.remoteHosts", + ); + } + }); + it("gives helpful error for invalid cors", () => { - assertThrows( + const error = assertThrows( () => validateVeryfrontConfig({ security: { cors: { origin: 123 } } }), Error, "Invalid veryfront.config at security.cors:", + ) as Error; + assertStringIncludes( + error.message, + "Expected boolean or a CORS object with origin, credentials, methods, allowedHeaders, exposedHeaders, or maxAge.", + ); + }); + + it("accepts the complete runtime CORS configuration contract", () => { + const origin = (requestOrigin: string) => requestOrigin === "https://example.com"; + const cors = { + origin, + credentials: true, + methods: ["GET", "POST"], + allowedHeaders: ["Authorization"], + exposedHeaders: ["X-Request-Id"], + maxAge: 3600, + }; + + assertEquals(validateVeryfrontConfig({ security: { cors } }).security?.cors, cors); + assertEquals( + validateVeryfrontConfig({ + security: { cors: { origin: ["https://example.com"] } }, + }).security?.cors, + { origin: ["https://example.com"] }, + ); + }); + + it("rejects unsafe or malformed CORS configuration", () => { + for ( + const cors of [ + { origin: "*", credentials: true }, + { origin: [] }, + { origin: [""] }, + { origin: "https://example.com\r\nX-Injected: yes" }, + { origin: "https://例.example" }, + { origin: " https://example.com" }, + { methods: [] }, + { methods: [""] }, + { methods: ["GET, POST"] }, + { methods: ["GET\nInjected"] }, + { allowedHeaders: [] }, + { allowedHeaders: ["X Invalid"] }, + { exposedHeaders: [] }, + { exposedHeaders: ["X-Valid\r\nInjected"] }, + { origin: "a".repeat(MAX_CORS_ORIGIN_LENGTH + 1) }, + { + origin: Array.from( + { length: MAX_CORS_ORIGIN_COUNT + 1 }, + (_, index) => `https://origin-${index}.example`, + ), + }, + { + origin: Array.from( + { length: 5 }, + (_, index) => `${index}${"a".repeat(MAX_CORS_ORIGIN_LENGTH - 1)}`, + ), + }, + { + methods: Array.from( + { length: MAX_CORS_TOKEN_COUNT + 1 }, + (_, index) => `M-${index}`, + ), + }, + { allowedHeaders: ["X".repeat(MAX_CORS_TOKEN_LENGTH + 1)] }, + { + exposedHeaders: Array.from( + { length: 17 }, + (_, index) => + `${"X".repeat(MAX_CORS_TOKEN_LENGTH - 3)}${String(index).padStart(3, "0")}`, + ), + }, + { maxAge: -1 }, + { maxAge: 1.5 }, + { maxAge: Number.NaN }, + { maxAge: Number.POSITIVE_INFINITY }, + { maxAge: Number.MAX_SAFE_INTEGER + 1 }, + { headers: ["Authorization"] }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ security: { cors } }), + Error, + "Invalid veryfront.config at security.cors", + ); + } + }); + + it("accepts bounded canonical CSRF customization", () => { + const csrf = { + cookieName: "__Host-vf_csrf", + headerName: "X-CSRF-Token", + excludePaths: ["/api/webhooks", "/health%20check"], + ttlSec: 3600, + }; + + assertEquals( + validateVeryfrontConfig({ security: { csrf } }).security?.csrf, + csrf, + ); + }); + + it("rejects CSRF names and exclusion paths that are unsafe or non-canonical", () => { + for ( + const csrf of [ + { excludePaths: [""] }, + { excludePaths: ["relative/path"] }, + { excludePaths: ["//example.com/api"] }, + { excludePaths: ["/api/../admin"] }, + { excludePaths: ["/api/"] }, + { excludePaths: ["/api?public=true"] }, + { excludePaths: ["/api#public"] }, + { excludePaths: ["/api\npublic"] }, + { excludePaths: [`/${"a".repeat(4096)}`] }, + { + excludePaths: Array.from( + { length: 65 }, + (_, index) => `/excluded-${index}`, + ), + }, + { + excludePaths: Array.from( + { length: 64 }, + (_, index) => `/excluded-${index}-${"a".repeat(256)}`, + ), + }, + { cookieName: "" }, + { cookieName: "csrf cookie" }, + { cookieName: "csrf;SameSite=None" }, + { cookieName: "csrf\r\nInjected" }, + { cookieName: "x".repeat(257) }, + { headerName: "" }, + { headerName: "x csrf" }, + { headerName: "x-csrf\r\nInjected" }, + { headerName: "x".repeat(257) }, + { ttlSec: 0 }, + { ttlSec: 1.5 }, + { ttlSec: Number.POSITIVE_INFINITY }, + { ttlSec: Number.MAX_SAFE_INTEGER + 1 }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ security: { csrf } }), + Error, + "Invalid veryfront.config at security.csrf", + ); + } + }); + + it("retains the supported bundle manifest backends", () => { + for (const type of ["redis", "kv", "memory"] as const) { + const config = validateVeryfrontConfig({ cache: { bundleManifest: { type } } }); + assertEquals(config.cache?.bundleManifest?.type, type); + } + }); + + it("rejects unwired distributed render-cache configuration", () => { + for ( + const render of [ + { type: "distributed" }, + { type: "distributed", keyPrefix: "vf:cache:tenant-render:" }, + { type: "memory", keyPrefix: "vf:cache:tenant-render:" }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ cache: { render } }), + Error, + "Invalid veryfront.config at cache.render", + ); + } + + for (const maxEntries of [-1, 0, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + assertThrows( + () => validateVeryfrontConfig({ cache: { render: { maxEntries } } }), + Error, + "Invalid veryfront.config at cache.render.maxEntries:", + ); + } + }); + + it("rejects unknown and cross-backend render cache options", () => { + for ( + const render of [ + { type: "memory", endpoint: "https://cache.invalid" }, + { type: "memory", keyPrefix: "vf:cache:tenant-render:" }, + { type: "filesystem", kvPath: "/tmp/cache.sqlite" }, + { type: "kv", keyPrefix: "vf:cache:tenant-render:" }, + { type: "memory", typoMaxEntry: 100 }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ cache: { render } }), + Error, + "Invalid veryfront.config at cache.render", + ); + } + + assertEquals( + validateVeryfrontConfig({ cache: { render: { type: "memory", maxEntries: 100 } } }) + .cache?.render?.type, + "memory", + ); + assertEquals( + validateVeryfrontConfig({ cache: { render: { type: "kv", kvPath: "/cache.sqlite" } } }) + .cache?.render?.type, + "kv", ); }); + it("enforces query parameter policy-specific configuration", () => { + for ( + const queryParams of [ + { policy: "ignore-all", params: ["page"] }, + { policy: "include-all", params: ["page"] }, + { policy: "include-list" }, + { policy: "include-list", params: [] }, + { policy: "exclude-list", params: [""] }, + { policy: "exclude-list", unknown: true }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ cache: { queryParams } }), + Error, + "Invalid veryfront.config at cache.queryParams", + ); + } + + for ( + const queryParams of [ + {}, + { policy: "ignore-all" }, + { policy: "include-all" }, + { policy: "include-list", params: ["page", "sort"] }, + { policy: "exclude-list", params: ["utm_source"] }, + { params: ["utm_source"] }, + ] + ) { + assertEquals( + validateVeryfrontConfig({ cache: { queryParams } }).cache?.queryParams !== undefined, + true, + ); + } + }); + + it("aligns cache TTL validation with each runtime contract", () => { + const valid = validateVeryfrontConfig({ + cache: { + bundleManifest: { ttl: 0 }, + render: { ttl: 0.5 }, + }, + fs: { + type: "veryfront-api", + veryfront: { cache: { ttl: 1, maxSize: 2, maxMemory: 3 } }, + }, + }); + const validGithub = validateVeryfrontConfig({ + fs: { + type: "github", + github: { + token: "token", + owner: "owner", + repo: "repo", + cache: { ttl: 1 }, + }, + }, + }); + assertEquals(valid.cache?.bundleManifest?.ttl, 0); + assertEquals(valid.cache?.render?.ttl, 0.5); + assertEquals(valid.fs?.veryfront?.cache?.maxMemory, 3); + assertEquals(validGithub.fs?.github?.cache?.ttl, 1); + + for (const ttl of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + assertThrows( + () => validateVeryfrontConfig({ cache: { bundleManifest: { ttl } } }), + Error, + "Invalid veryfront.config at cache.bundleManifest.ttl:", + ); + } + + for ( + const ttl of [ + 0, + -1, + Number.POSITIVE_INFINITY, + MAX_CACHE_TTL_MILLISECONDS + 1, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ cache: { render: { ttl } } }), + Error, + "Invalid veryfront.config at cache.render.ttl:", + ); + } + + for (const ttl of [0, -1, 0.5, MAX_CACHE_TTL_MILLISECONDS + 1]) { + assertThrows( + () => + validateVeryfrontConfig({ + fs: { type: "veryfront-api", veryfront: { cache: { ttl } } }, + }), + Error, + "Invalid veryfront.config at fs.veryfront.cache.ttl:", + ); + assertThrows( + () => + validateVeryfrontConfig({ + fs: { + type: "github", + github: { token: "token", owner: "owner", repo: "repo", cache: { ttl } }, + }, + }), + Error, + "Invalid veryfront.config at fs.github.cache.ttl:", + ); + } + + for ( + const cache of [ + { maxSize: Number.MAX_SAFE_INTEGER + 1 }, + { maxMemory: Number.MAX_SAFE_INTEGER + 1 }, + ] + ) { + assertThrows( + () => + validateVeryfrontConfig({ + fs: { type: "veryfront-api", veryfront: { cache } }, + }), + Error, + "Invalid veryfront.config at fs.veryfront.cache.", + ); + assertThrows( + () => + validateVeryfrontConfig({ + fs: { + type: "github", + github: { token: "token", owner: "owner", repo: "repo", cache }, + }, + }), + Error, + "Invalid veryfront.config at fs.github.cache.", + ); + } + }); + it("accepts only the canonical source integration narrowing policy", () => { const cfg = validateVeryfrontConfig({ integrations: { @@ -136,5 +964,27 @@ describe("configSchema", () => { Error, "Expected a canonical connector-local tool ID", ); + const firstHalfCount = Math.floor(MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS / 2) + 1; + const firstHalf = Array.from( + { length: firstHalfCount }, + (_, index) => `tool_a_${index}`, + ); + const secondHalf = Array.from( + { length: MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS + 1 - firstHalfCount }, + (_, index) => `tool_b_${index}`, + ); + assertThrows( + () => + validateVeryfrontConfig({ + integrations: { + allow: { + github: { allowedTools: firstHalf }, + gmail: { allowedTools: secondHalf }, + }, + }, + }), + Error, + "Source integration allowlist exceeds resource limits", + ); }); }); diff --git a/src/config/schemas/config.schema.ts b/src/config/schemas/config.schema.ts index b6d6e63680..8e3d6f49af 100644 --- a/src/config/schemas/config.schema.ts +++ b/src/config/schemas/config.schema.ts @@ -1,31 +1,267 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import { isAbsolute } from "#veryfront/compat/path/index.ts"; import type { InferInput, InferSchema } from "#veryfront/extensions/schema/index.ts"; -import { type ConfigContext, createError, toError } from "#veryfront/errors/veryfront-error.ts"; +import { CONFIG_VALIDATION_FAILED } from "#veryfront/errors/error-registry.ts"; +import { + MAX_REMOTE_INTEGRATION_TOOL_NAME_LENGTH, + MAX_SOURCE_INTEGRATION_POLICY_INTEGRATIONS, + MAX_SOURCE_INTEGRATION_POLICY_SEGMENT_LENGTH, + MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS, +} from "#veryfront/integrations/limits.ts"; import { ALL_INTEGRATION_NAMES } from "#veryfront/integrations/schema.ts"; -import type { SourceIntegrationPolicyConfig } from "#veryfront/integrations/source-policy.ts"; +import type { + SourceIntegrationPolicyConfig, + SourceIntegrationRestriction, +} from "#veryfront/integrations/source-policy.ts"; +import { MAX_CACHE_TTL_MILLISECONDS } from "#veryfront/cache/backends/ttl.ts"; +import { MAX_PORT, MIN_PORT } from "#veryfront/utils/constants/network.ts"; +import { + HTTP_TOKEN_PATTERN, + isBoundedCorsOrigin, + isBoundedCorsOriginList, + isBoundedCorsTokenList, + MAX_CORS_MAX_AGE, + MAX_CORS_ORIGIN_COUNT, + MAX_CORS_ORIGIN_LENGTH, + MAX_CORS_TOKEN_COUNT, + MAX_CORS_TOKEN_LENGTH, +} from "#veryfront/utils/cors-policy-limits.ts"; +import { + MAX_REMOTE_HOST_COUNT, + MAX_REMOTE_HOST_URL_LENGTH, +} from "#veryfront/utils/remote-host-policy-limits.ts"; +import { + MAX_FILE_LOG_FILES, + MAX_GITHUB_FILESYSTEM_ATTEMPTS, + MAX_VERYFRONT_FILESYSTEM_RETRIES, +} from "#veryfront/utils/config-resource-limits.ts"; +import { + MAX_CSRF_NAME_LENGTH, + MAX_CSRF_TTL_SECONDS, + MAX_PATH_LENGTH, +} from "#veryfront/utils/constants/security.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { CSS_OPTIMIZATION, IMAGE_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; +import { + isProjectRelativeDiscoveryPath, + MAX_PROJECT_DISCOVERY_DIRECTORIES, +} from "#veryfront/utils/discovery-path-policy.ts"; import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { isCanonicalProjectRelativePath } from "#veryfront/utils/project-relative-path.ts"; const integrationNames = new Set(ALL_INTEGRATION_NAMES); +const MAX_CSRF_EXCLUDE_PATH_COUNT = 64; +const MAX_CSRF_EXCLUDE_PATH_LIST_LENGTH = 16_384; +const CSRF_EXCLUDE_PATH_BASE_URL = "https://csrf-policy.invalid"; + +function isBoundedSourceIntegrationAllowlist( + allow: Readonly>, +): boolean { + const entries = Object.entries(allow); + if (entries.length > MAX_SOURCE_INTEGRATION_POLICY_INTEGRATIONS) return false; + + let totalToolIds = 0; + for (const [integration, restriction] of entries) { + const allowedTools = restriction.allowedTools; + if (!allowedTools) continue; + if (allowedTools.length > MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS) return false; + for (const toolId of allowedTools) { + if ( + ++totalToolIds > MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS || + integration.length + 2 + toolId.length > MAX_REMOTE_INTEGRATION_TOOL_NAME_LENGTH + ) { + return false; + } + } + } + return true; +} + +function isCanonicalCsrfExcludePath(path: string): boolean { + if ( + path.length === 0 || + path.length > MAX_PATH_LENGTH || + !path.startsWith("/") || + path.startsWith("//") || + (path.length > 1 && path.endsWith("/")) + ) { + return false; + } + + try { + const parsed = new URL(path, CSRF_EXCLUDE_PATH_BASE_URL); + return parsed.origin === CSRF_EXCLUDE_PATH_BASE_URL && + parsed.pathname === path && + parsed.search === "" && + parsed.hash === ""; + } catch { + return false; + } +} + +function isBoundedCsrfExcludePathList(paths: readonly string[]): boolean { + let serializedLength = 0; + for (const path of paths) { + serializedLength += path.length; + if (serializedLength > MAX_CSRF_EXCLUDE_PATH_LIST_LENGTH) return false; + } + return true; +} // Sub-schemas +type CorsOriginValidator = ( + origin: string, +) => boolean | string; + +const getCorsOriginSchema = defineSchema((v) => + v.union([ + v + .string() + .min(1) + .max(MAX_CORS_ORIGIN_LENGTH) + .refine(isBoundedCorsOrigin, "Expected a bounded CORS origin without control characters"), + v + .array( + v + .string() + .min(1) + .max(MAX_CORS_ORIGIN_LENGTH) + .refine(isBoundedCorsOrigin, "Expected a CORS origin without control characters"), + ) + .min(1) + .max(MAX_CORS_ORIGIN_COUNT) + .refine(isBoundedCorsOriginList, "CORS origin list exceeds its aggregate size limit"), + v.custom( + (value) => typeof value === "function", + "Expected a CORS origin, origin list, or origin validator", + ), + ]) +); + const getCorsSchema = defineSchema((v) => - v.union([v.boolean(), v.object({ origin: v.string().optional() }).strict()]) + v.union([ + v.boolean(), + v.object({ + origin: getCorsOriginSchema().optional(), + credentials: v.boolean().optional(), + methods: v + .array( + v.string().max(MAX_CORS_TOKEN_LENGTH).regex( + HTTP_TOKEN_PATTERN, + "Expected a valid HTTP method", + ), + ) + .min(1) + .max(MAX_CORS_TOKEN_COUNT) + .refine(isBoundedCorsTokenList, "CORS methods exceed their aggregate size limit") + .optional(), + allowedHeaders: v + .array( + v.string().max(MAX_CORS_TOKEN_LENGTH).regex( + HTTP_TOKEN_PATTERN, + "Expected a valid HTTP header name", + ), + ) + .min(1) + .max(MAX_CORS_TOKEN_COUNT) + .refine(isBoundedCorsTokenList, "CORS allowed headers exceed their aggregate size limit") + .optional(), + exposedHeaders: v + .array( + v.string().max(MAX_CORS_TOKEN_LENGTH).regex( + HTTP_TOKEN_PATTERN, + "Expected a valid HTTP header name", + ), + ) + .min(1) + .max(MAX_CORS_TOKEN_COUNT) + .refine(isBoundedCorsTokenList, "CORS exposed headers exceed their aggregate size limit") + .optional(), + maxAge: v.number().int().nonnegative().max(MAX_CORS_MAX_AGE).optional(), + }).strict().refine( + (cors) => !(cors.origin === "*" && cors.credentials), + "Cannot use credentials with wildcard origin (*)", + ), + ]) +); + +const getCsrfSchema = defineSchema((v) => + v.union([ + v.boolean(), + v.object({ + cookieName: v + .string() + .min(1) + .max(MAX_CSRF_NAME_LENGTH) + .regex(HTTP_TOKEN_PATTERN, "Expected a valid cookie name") + .optional(), + headerName: v + .string() + .min(1) + .max(MAX_CSRF_NAME_LENGTH) + .regex(HTTP_TOKEN_PATTERN, "Expected a valid HTTP header name") + .optional(), + excludePaths: v + .array( + v + .string() + .min(1) + .max(MAX_PATH_LENGTH) + .refine( + isCanonicalCsrfExcludePath, + "Expected a canonical absolute URL path without a query, fragment, or trailing slash", + ), + ) + .max(MAX_CSRF_EXCLUDE_PATH_COUNT) + .refine( + isBoundedCsrfExcludePathList, + "CSRF exclusion paths exceed their aggregate size limit", + ) + .optional(), + ttlSec: v.number().int().positive().max(MAX_CSRF_TTL_SECONDS).optional(), + }).strict(), + ]) ); const getBasicAuthSchema = defineSchema((v) => v.object({ - username: v.string(), - password: v.string(), + username: v.string().min(1), + password: v.string().min(1), realm: v.string().optional(), - }) + }).strict() ); const getBearerAuthSchema = defineSchema((v) => v.object({ - token: v.string(), - }) + token: v.string().min(1), + }).strict() +); + +function defineFilesystemRetrySchema(maxConfiguredCount: number) { + return defineSchema((v) => + v + .object({ + maxRetries: v.number().int().min(0).max(maxConfiguredCount).optional(), + initialDelay: v.number().int().min(0).max(MAX_TIMER_DELAY_MS).optional(), + maxDelay: v.number().int().min(0).max(MAX_TIMER_DELAY_MS).optional(), + }) + .partial() + .strict() + .refine( + (retry) => + retry.initialDelay === undefined || + retry.maxDelay === undefined || + retry.initialDelay <= retry.maxDelay, + "Filesystem retry initialDelay must not exceed maxDelay", + ) + ); +} + +const getVeryfrontFilesystemRetrySchema = defineFilesystemRetrySchema( + MAX_VERYFRONT_FILESYSTEM_RETRIES, +); +const getGitHubFilesystemRetrySchema = defineFilesystemRetrySchema( + MAX_GITHUB_FILESYSTEM_ATTEMPTS, ); const getEmbeddingDimensionSchema = defineSchema((v) => @@ -38,6 +274,36 @@ const getEmbeddingDimensionSchema = defineSchema((v) => ]) ); +const getProjectDiscoveryPathSchema = defineSchema((v) => + v + .string() + .min(1) + .max(MAX_PATH_LENGTH_CHARS) + .refine( + isProjectRelativeDiscoveryPath, + "Expected a canonical project-relative discovery path", + ) +); + +const getAiDiscoveryContainerSchema = defineSchema((v) => + v + .object({ + discovery: v + .object({ + enabled: v.boolean().optional(), + paths: v + .array(getProjectDiscoveryPathSchema()) + .max(MAX_PROJECT_DISCOVERY_DIRECTORIES) + .optional(), + }) + .partial() + .strict() + .optional(), + }) + .partial() + .strict() +); + // Main config schema export const getVeryfrontConfigSchema = defineSchema((v) => v @@ -51,6 +317,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => version: v.string().optional(), }) .partial() + .strict() .optional(), directories: v .object({ @@ -60,6 +327,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => ai: v.string().optional(), }) .partial() + .strict() .optional(), experimental: v .object({ @@ -68,13 +336,18 @@ export const getVeryfrontConfigSchema = defineSchema((v) => rsc: v.boolean().optional(), }) .partial() + .strict() .optional(), router: v.enum(["app", "pages"]).optional(), /** Path to the layout component (e.g., 'components/layout.tsx'), or false to disable */ layout: v.union([v.string(), v.literal(false)]).optional(), /** Path to the app wrapper component (e.g., 'components/app.tsx'), or false to disable */ app: v.union([v.string(), v.literal(false)]).optional(), - theme: v.object({ colors: v.record(v.string(), v.string()).optional() }).partial().optional(), + theme: v + .object({ colors: v.record(v.string(), v.string()).optional() }) + .partial() + .strict() + .optional(), build: v .object({ outDir: v.string().optional(), @@ -91,9 +364,11 @@ export const getVeryfrontConfigSchema = defineSchema((v) => worker: v.boolean().optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), cache: v .object({ @@ -102,22 +377,67 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .object({ type: v.enum(["redis", "kv", "memory"]).optional(), redisUrl: v.string().optional(), - keyPrefix: v.string().optional(), - ttl: v.number().int().positive().optional(), + keyPrefix: v.string().max(512).optional(), + ttl: v.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), enabled: v.boolean().optional(), }) .partial() + .strict() .optional(), render: v .object({ type: v.enum(["memory", "filesystem", "kv", "redis"]).optional(), - ttl: v.number().optional(), - maxEntries: v.number().optional(), + ttl: v.number().positive().max(MAX_CACHE_TTL_MILLISECONDS).optional(), + maxEntries: v.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), kvPath: v.string().optional(), + /** Legacy Redis connection settings retained for the built-in Redis backend. */ redisUrl: v.string().optional(), - redisKeyPrefix: v.string().optional(), + redisKeyPrefix: v.string().max(512).optional(), + /** + * Explicit contract for caching SSR responses that execute + * request-aware project data hooks. Disabled unless opted in. + */ + public: v + .object({ + enabled: v.boolean().optional(), + /** Request headers whose values are part of the public response identity. */ + varyHeaders: v + .array( + v.string().regex( + HTTP_TOKEN_PATTERN, + "Expected a valid HTTP header name", + ), + ) + .max(32) + .optional(), + }) + .partial() + .strict() + .optional(), }) .partial() + .strict() + .refine( + (config) => { + const type = config.type ?? "memory"; + if (type === "memory" || type === "filesystem") { + return config.kvPath === undefined && + config.redisUrl === undefined && + config.redisKeyPrefix === undefined; + } + if (type === "kv") { + return config.maxEntries === undefined && + config.redisUrl === undefined && + config.redisKeyPrefix === undefined; + } + if (type === "redis") { + return config.maxEntries === undefined && + config.kvPath === undefined; + } + return false; + }, + "Render cache options must belong to the selected backend type", + ) .optional(), /** * Query parameter handling for page cache keys. @@ -137,28 +457,34 @@ export const getVeryfrontConfigSchema = defineSchema((v) => * // Only vary cache by specific params * cache: { queryParams: { policy: "include-list", params: ["page", "sort"] } } */ - queryParams: v - .object({ - policy: v.enum(["ignore-all", "include-all", "include-list", "exclude-list"]) - .optional(), - params: v.array(v.string()).optional(), - }) - .partial() - .optional(), + queryParams: v.union([ + v.object({ policy: v.literal("ignore-all") }).strict(), + v.object({ policy: v.literal("include-all") }).strict(), + v.object({ + policy: v.literal("include-list"), + params: v.array(v.string().min(1).max(256)).min(1).max(128), + }).strict(), + v.object({ + policy: v.literal("exclude-list").optional(), + params: v.array(v.string().min(1).max(256)).max(128).optional(), + }).strict(), + ]).optional(), }) .partial() + .strict() .optional(), dev: v .object({ - port: v.number().int().positive().optional(), + port: v.number().int().min(MIN_PORT).max(MAX_PORT).optional(), host: v.string().optional(), open: v.boolean().optional(), hmr: v.boolean().optional(), - hmrPort: v.number().optional(), + hmrPort: v.number().int().min(MIN_PORT).max(MAX_PORT).optional(), components: v.array(v.string()).optional(), moduleServerUrl: v.string().optional(), }) .partial() + .strict() .optional(), resolve: v .object({ @@ -168,9 +494,11 @@ export const getVeryfrontConfigSchema = defineSchema((v) => scopes: v.record(v.string(), v.record(v.string(), v.string())).optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), security: v .object({ @@ -180,9 +508,17 @@ export const getVeryfrontConfigSchema = defineSchema((v) => bearer: getBearerAuthSchema().optional(), }) .partial() + .strict() + .refine( + (auth) => !(auth.basic && auth.bearer), + "Configure either basic or bearer authentication, not both", + ) .optional(), csp: v.record(v.string(), v.array(v.string())).optional(), - remoteHosts: v.array(v.string().url()).optional(), + remoteHosts: v + .array(v.string().max(MAX_REMOTE_HOST_URL_LENGTH).url()) + .max(MAX_REMOTE_HOST_COUNT) + .optional(), cors: getCorsSchema().optional(), /** * CSRF protection using the double-submit cookie pattern. @@ -191,37 +527,37 @@ export const getVeryfrontConfigSchema = defineSchema((v) => * When enabled, POST/PUT/PATCH/DELETE requests must include * an `x-csrf-token` header matching the `__Host-vf_csrf` cookie. * The cookie is set automatically on HTML document responses. + * Custom names must use HTTP token syntax. Exclusions must be + * canonical absolute URL paths without queries, fragments, or + * trailing slashes. * * Server Actions (`/_veryfront/rsc/action`) are CSRF-protected; - * client code must forward the cookie value as the header. + * client code must forward the cookie value as the header. CSRF is + * separate from the required `RscActionAuthorizationProvider` + * extension contract and does not replace action authorization. */ - csrf: v.union([ - v.boolean(), - v.object({ - cookieName: v.string().optional(), - headerName: v.string().optional(), - excludePaths: v.array(v.string()).optional(), - ttlSec: v.number().int().positive().optional(), - }).strict(), - ]).optional(), + csrf: getCsrfSchema().optional(), coop: v.enum(["same-origin", "same-origin-allow-popups", "unsafe-none"]).optional(), corp: v.enum(["same-origin", "same-site", "cross-origin"]).optional(), coep: v.enum(["require-corp", "unsafe-none"]).optional(), /** * Restrict module imports to specific directories (opt-in security). * When not set, users can import from any directory in the project. - * When set, only imports from these directories are allowed. + * When set, only imports from these directories are allowed; an + * explicit empty array denies imports from every project directory. * @example ["app", "pages", "components", "lib", "src", "utils"] */ allowedImportDirs: v.array(v.string()).optional(), }) .partial() + .strict() .optional(), middleware: v .object({ custom: v.array(v.any()).optional(), }) .partial() + .strict() .optional(), theming: v .object({ @@ -229,28 +565,51 @@ export const getVeryfrontConfigSchema = defineSchema((v) => logoHtml: v.string().optional(), }) .partial() + .strict() .optional(), assetPipeline: v .object({ images: v .object({ enabled: v.boolean().optional(), - projectDir: v.string().optional(), - formats: v.array(v.enum(["webp", "avif", "jpeg", "png"])).optional(), + projectDir: v + .string() + .min(1) + .max(MAX_PATH_LENGTH_CHARS) + .refine( + isAbsolute, + "Image projectDir must be an absolute path", + ) + .optional(), + formats: v + .array(v.enum(["webp", "avif", "jpeg", "png"])) + .min(1) + .max(4) + .refine( + (formats) => new Set(formats).size === formats.length, + "Image formats must be unique", + ) + .optional(), sizes: v .array( v.number().int().positive().max( IMAGE_OPTIMIZATION.MAX_DIMENSION, ), ) + .min(1) .max(IMAGE_OPTIMIZATION.MAX_OUTPUT_SIZES) + .refine( + (sizes) => new Set(sizes).size === sizes.length, + "Image sizes must be unique", + ) .optional(), quality: v.number().int().min(1).max(100).optional(), - inputDir: v.string().optional(), - outputDir: v.string().optional(), + inputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), + outputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), preserveOriginal: v.boolean().optional(), }) .partial() + .strict() .optional(), css: v .object({ @@ -265,6 +624,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => ) .optional(), minify: v.boolean().optional(), + autoprefixer: v.boolean().optional(), purge: v.boolean().optional(), criticalCSS: v.boolean().optional(), inputFiles: v @@ -273,6 +633,15 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .optional(), inputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), outputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), + browsers: v + .array( + v.string().min(1).max( + CSS_OPTIMIZATION.MAX_BROWSER_QUERY_CHARACTERS, + ), + ) + .min(1) + .max(CSS_OPTIMIZATION.MAX_BROWSER_QUERIES) + .optional(), purgeContent: v .array(v.string().min(1).max(MAX_PATH_LENGTH_CHARS)) .max(CSS_OPTIMIZATION.MAX_PURGE_PATTERNS) @@ -283,10 +652,27 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .optional(), sourceMap: v.boolean().optional(), }) + .partial() .strict() + .refine( + (options) => options.criticalCSS !== true, + "Batch criticalCSS is unsupported; call extractCriticalCSS explicitly", + ) + .refine( + (options) => !(options.purge === true && options.sourceMap === true), + "CSS purge and sourceMap cannot be enabled together", + ) + .refine( + (options) => + options.purge !== true || + options.purgeContent === undefined || + options.purgeContent.length > 0, + "Enabled CSS purge requires non-empty purgeContent", + ) .optional(), }) .partial() + .strict() .optional(), observability: v .object({ @@ -299,6 +685,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => sampleRate: v.number().min(0).max(1).optional(), }) .partial() + .strict() .optional(), metrics: v .object({ @@ -309,6 +696,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => collectInterval: v.number().int().positive().optional(), }) .partial() + .strict() .optional(), logging: v .object({ @@ -317,17 +705,21 @@ export const getVeryfrontConfigSchema = defineSchema((v) => enabled: v.boolean().optional(), path: v.string().optional(), maxSize: v.union([v.number().int().positive(), v.string()]).optional(), - maxFiles: v.number().int().positive().optional(), + /** Total retained files, including the active file. */ + maxFiles: v.number().int().positive().max(MAX_FILE_LOG_FILES).optional(), level: v.enum(["debug", "info", "warn", "error"]).optional(), format: v.enum(["json", "text"]).optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), search: v .object({ @@ -341,6 +733,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => batchSize: v.number().int().positive().optional(), }) .partial() + .strict() .optional(), chunking: v .object({ @@ -350,15 +743,21 @@ export const getVeryfrontConfigSchema = defineSchema((v) => exclude: v.array(v.string()).optional(), }) .partial() + .strict() .optional(), autoIndex: v.boolean().optional(), }) .partial() + .strict() .optional(), fs: v .object({ type: v.enum(["local", "veryfront-api", "memory", "github"]).optional(), - local: v.object({ baseDir: v.string().optional() }).partial().optional(), + local: v + .object({ baseDir: v.string().optional() }) + .partial() + .strict() + .optional(), veryfront: v .object({ apiBaseUrl: v.string().url(), @@ -373,21 +772,17 @@ export const getVeryfrontConfigSchema = defineSchema((v) => cache: v .object({ enabled: v.boolean().optional(), - ttl: v.number().int().positive().optional(), - maxSize: v.number().int().positive().optional(), - }) - .partial() - .optional(), - retry: v - .object({ - maxRetries: v.number().int().min(0).optional(), - initialDelay: v.number().int().positive().optional(), - maxDelay: v.number().int().positive().optional(), + ttl: v.number().int().positive().max(MAX_CACHE_TTL_MILLISECONDS).optional(), + maxSize: v.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), + maxMemory: v.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), }) .partial() + .strict() .optional(), + retry: getVeryfrontFilesystemRetrySchema().optional(), }) .partial() + .strict() .optional(), memory: v .object({ @@ -395,6 +790,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .optional(), }) .partial() + .strict() .optional(), github: v .object({ @@ -409,24 +805,46 @@ export const getVeryfrontConfigSchema = defineSchema((v) => cache: v .object({ enabled: v.boolean().optional(), - ttl: v.number().int().positive().optional(), - maxSize: v.number().int().positive().optional(), - maxMemory: v.number().int().positive().optional(), - }) - .partial() - .optional(), - retry: v - .object({ - maxRetries: v.number().int().min(0).optional(), - initialDelay: v.number().int().positive().optional(), - maxDelay: v.number().int().positive().optional(), + ttl: v.number().int().positive().max(MAX_CACHE_TTL_MILLISECONDS).optional(), + maxSize: v.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), + maxMemory: v.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), }) .partial() + .strict() .optional(), + retry: getGitHubFilesystemRetrySchema().optional(), }) + .strict() .optional(), }) .partial() + .strict() + .refine( + (config) => { + const type = config.type ?? "local"; + if (type === "local") { + return config.veryfront === undefined && + config.memory === undefined && + config.github === undefined; + } + if (type === "veryfront-api") { + return config.veryfront !== undefined && + config.local === undefined && + config.memory === undefined && + config.github === undefined; + } + if (type === "memory") { + return config.local === undefined && + config.veryfront === undefined && + config.github === undefined; + } + return config.github !== undefined && + config.local === undefined && + config.veryfront === undefined && + config.memory === undefined; + }, + "Filesystem options must belong to the selected backend type", + ) .optional(), ai: v .object({ @@ -440,148 +858,29 @@ export const getVeryfrontConfigSchema = defineSchema((v) => organization: v.string().optional(), }).passthrough(), ).optional(), - tools: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - agents: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - skills: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - resources: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - prompts: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - workflows: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - work: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - tasks: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - schedules: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - webhooks: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), - evals: v - .object({ - discovery: v - .object({ - enabled: v.boolean().optional(), - paths: v.array(v.string()).optional(), - }) - .partial() - .optional(), - }) - .partial() - .optional(), + tools: getAiDiscoveryContainerSchema().optional(), + agents: getAiDiscoveryContainerSchema().optional(), + skills: getAiDiscoveryContainerSchema().optional(), + resources: getAiDiscoveryContainerSchema().optional(), + prompts: getAiDiscoveryContainerSchema().optional(), + workflows: getAiDiscoveryContainerSchema().optional(), + work: getAiDiscoveryContainerSchema().optional(), + tasks: getAiDiscoveryContainerSchema().optional(), + schedules: getAiDiscoveryContainerSchema().optional(), + webhooks: getAiDiscoveryContainerSchema().optional(), + evals: getAiDiscoveryContainerSchema().optional(), mcp: v .object({ enabled: v.boolean().optional(), - port: v.number().optional(), + port: v.number().int().min(MIN_PORT).max(MAX_PORT).optional(), expose: v.array(v.string()).optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), client: v .object({ @@ -598,14 +897,16 @@ export const getVeryfrontConfigSchema = defineSchema((v) => v.object({ react: v.string().optional(), veryfront: v.string().optional(), - }), + }).strict(), ]) .optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), /** CLI generate command preferences */ generate: v @@ -614,25 +915,46 @@ export const getVeryfrontConfigSchema = defineSchema((v) => preferredRouter: v.enum(["app-router", "pages-router"]).optional(), }) .partial() + .strict() .optional(), - tailwind: v + /** Provider-neutral stylesheet selection for CSS processor extensions. */ + styles: v .object({ /** Path to the global stylesheet (default: "globals.css") */ + stylesheet: v + .string() + .min(1) + .max(MAX_PATH_LENGTH_CHARS) + .refine( + isCanonicalProjectRelativePath, + "Expected a canonical project-relative stylesheet path", + ) + .optional(), + }) + .partial() + .strict() + .optional(), + /** + * Tailwind-specific authoring retained for existing projects. New + * provider-neutral stylesheet selection should use `styles`. + */ + tailwind: v + .object({ stylesheet: v.string().optional(), - /** Enable built-in Tailwind CDN plugins (forms, typography, aspect-ratio, container-queries) */ - plugins: v.array(v.enum(["forms", "typography", "aspect-ratio", "container-queries"])) + plugins: v + .array(v.enum(["forms", "typography", "aspect-ratio", "container-queries"])) .optional(), - /** Extend the Tailwind theme (merged with veryfront defaults) */ theme: v .object({ extend: v.record(v.string(), v.unknown()).optional(), }) .partial() + .strict() .optional(), - /** Custom CSS content to add (for @layer, @apply directives, etc.) */ customCSS: v.string().optional(), }) .partial() + .strict() .optional(), /** * Optional source-owned integration restrictions. @@ -644,7 +966,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => integrations: v .object({ allow: v.record( - v.string().min(1).refine( + v.string().min(1).max(MAX_SOURCE_INTEGRATION_POLICY_SEGMENT_LENGTH).refine( (name) => integrationNames.has(name), { message: "Expected a canonical integration name from the connector catalog" }, ), @@ -653,15 +975,20 @@ export const getVeryfrontConfigSchema = defineSchema((v) => /** Exact connector-local tool IDs; omit to allow all tools. */ allowedTools: v .array( - v.string().regex( - /^(?!.*__)[a-z0-9][a-z0-9_-]*$/, - "Expected a canonical connector-local tool ID", - ), + v.string() + .max(MAX_SOURCE_INTEGRATION_POLICY_SEGMENT_LENGTH) + .regex( + /^(?!.*__)[a-z0-9][a-z0-9_-]*$/, + "Expected a canonical connector-local tool ID", + ), ) + .max(MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS) .optional(), }) .strict(), - ), + ).refine(isBoundedSourceIntegrationAllowlist, { + message: "Source integration allowlist exceeds resource limits", + }), }) .strict() .optional(), @@ -700,6 +1027,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => docs: v.string().optional(), }) .partial() + .strict() .optional(), /** MCP integration configuration */ mcp: v @@ -712,12 +1040,15 @@ export const getVeryfrontConfigSchema = defineSchema((v) => toolPrefix: v.string().optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() .optional(), }) .partial() + .strict() ); export const veryfrontConfigSchema = lazySchema(getVeryfrontConfigSchema); @@ -744,29 +1075,22 @@ export function validateVeryfrontConfig(input: unknown): VeryfrontConfig { const path = first?.path?.length ? first.path.join(".") : ""; const expected = first?.message ?? String(first); const corsHint = path.includes("security.cors") - ? " Expected boolean or { origin?: string }." + ? " Expected boolean or a CORS object with origin, credentials, methods, allowedHeaders, exposedHeaders, or maxAge." : ""; const expectedWithHint = expected + corsHint; - const context: ConfigContext = { + const context = { field: path, expected: expectedWithHint, - value: input, }; - throw toError( - createError({ - type: "config", - message: `Invalid veryfront.config at ${path}: ${expectedWithHint}.`, - context, - }), - ); + throw CONFIG_VALIDATION_FAILED.create({ + detail: `Invalid veryfront.config at ${path}: ${expectedWithHint}.`, + context, + }); } -/** - * Known top-level keys from the config schema definition. - * Maintained in sync with the `getVeryfrontConfigSchema` shape above. - */ +/** Top-level project config keys recognized by the public schema. */ const knownConfigKeys = new Set([ "projectSlug", "title", @@ -792,6 +1116,7 @@ const knownConfigKeys = new Set([ "ai", "client", "generate", + "styles", "tailwind", "integrations", "extensions", diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index b9ede57490..fb9103cda5 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -4,24 +4,33 @@ * @module config/schemas */ -export { - findUnknownTopLevelKeys, - validateVeryfrontConfig, - type VeryfrontConfigInput, - veryfrontConfigSchema, -} from "./config.schema.ts"; +export { findUnknownTopLevelKeys, veryfrontConfigSchema } from "./config.schema.ts"; -import type { VeryfrontConfig as BaseVeryfrontConfig } from "./config.schema.ts"; +import { + validateVeryfrontConfig as validateBaseVeryfrontConfig, + type VeryfrontConfig as BaseVeryfrontConfig, + type VeryfrontConfigInput as BaseVeryfrontConfigInput, +} from "./config.schema.ts"; // Type-only reference — keeps the config layer free of a runtime dependency // on the extensions module. The schema stores `extensions` as `unknown[]` // at runtime; this type assertion tightens it at the TS layer. import type { ExtensionConfigEntry } from "#veryfront/extensions/types.ts"; /** - * Project configuration. The underlying zod schema stores `extensions` as + * Project configuration. The underlying runtime schema stores `extensions` as * `unknown[]`; this tightened alias surfaces the expected * `ExtensionConfigEntry[]` shape to TypeScript consumers. */ export type VeryfrontConfig = & Omit & { extensions?: ExtensionConfigEntry[] }; + +/** User-authored project configuration with typed extension entries. */ +export type VeryfrontConfigInput = + & Omit + & { extensions?: ExtensionConfigEntry[] }; + +/** Validate project config and expose the framework's public extension entry type. */ +export function validateVeryfrontConfig(input: unknown): VeryfrontConfig { + return validateBaseVeryfrontConfig(input) as VeryfrontConfig; +} diff --git a/src/config/snapshot.test.ts b/src/config/snapshot.test.ts new file mode 100644 index 0000000000..0729336b6f --- /dev/null +++ b/src/config/snapshot.test.ts @@ -0,0 +1,435 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + canonicalizeConfigSnapshot, + CONFIG_SNAPSHOT_LIMITS, + ConfigSnapshotError, + type ConfigSnapshotErrorCode, + type ConfigSnapshotRecord, + type ConfigSnapshotValue, +} from "./snapshot.ts"; + +function assertSnapshotError( + operation: () => unknown, + code: ConfigSnapshotErrorCode, +): ConfigSnapshotError { + const error = assertThrows(operation, ConfigSnapshotError) as ConfigSnapshotError; + assertEquals(error.code, code); + return error; +} + +function asRecord(value: ConfigSnapshotValue): ConfigSnapshotRecord { + return value as ConfigSnapshotRecord; +} + +describe("canonicalizeConfigSnapshot", () => { + it("creates a detached, deeply frozen canonical snapshot", () => { + const nullPrototype = Object.create(null) as Record; + nullPrototype.z = "last"; + nullPrototype.a = { enabled: true }; + const input = { + nested: nullPrototype, + list: [1, { value: "stable" }], + }; + + const snapshot = asRecord(canonicalizeConfigSnapshot(input)); + const nested = asRecord(snapshot.nested!); + const list = snapshot.list as readonly ConfigSnapshotValue[]; + const listRecord = asRecord(list[1]!); + + assertEquals(snapshot, { + list: [1, { value: "stable" }], + nested: { a: { enabled: true }, z: "last" }, + }); + assertEquals(Reflect.ownKeys(nested), ["a", "z"]); + assertEquals(Object.getPrototypeOf(snapshot), null); + assertEquals(Object.getPrototypeOf(nested), null); + assertEquals(Array.isArray(list), true); + assertEquals(Object.isFrozen(snapshot), true); + assertEquals(Object.isFrozen(nested), true); + assertEquals(Object.isFrozen(asRecord(nested.a!)), true); + assertEquals(Object.isFrozen(list), true); + assertEquals(Object.isFrozen(listRecord), true); + + nullPrototype.z = "mutated"; + (input.list[1] as { value: string }).value = "mutated"; + assertEquals(nested.z, "last"); + assertEquals(listRecord.value, "stable"); + assertThrows(() => Object.defineProperty(snapshot, "added", { value: true }), TypeError); + assertThrows(() => Object.defineProperty(list, "0", { value: 2 }), TypeError); + }); + + it("accepts the supported primitive values", () => { + assertEquals(canonicalizeConfigSnapshot(null), null); + assertEquals(canonicalizeConfigSnapshot(true), true); + assertEquals(canonicalizeConfigSnapshot(42.5), 42.5); + assertEquals(canonicalizeConfigSnapshot("value"), "value"); + }); + + it("never invokes getters while rejecting accessor properties", () => { + let getterCalls = 0; + const input = Object.create(null) as Record; + Object.defineProperty(input, "secret", { + enumerable: true, + get() { + getterCalls += 1; + return "leaked"; + }, + }); + + const error = assertSnapshotError( + () => canonicalizeConfigSnapshot(input), + "accessor-property", + ); + + assertEquals(getterCalls, 0); + assertEquals(error.path, '$["secret"]'); + }); + + it("builds descriptors safely when Object.prototype is polluted", () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, "get"); + let snapshot: ConfigSnapshotValue | undefined; + let failure: unknown; + Object.defineProperty(Object.prototype, "get", { + configurable: true, + get() { + throw new Error("descriptor prototype must not be read"); + }, + }); + try { + snapshot = canonicalizeConfigSnapshot({ + record: { enabled: true }, + values: ["safe"], + }); + } catch (error) { + failure = error; + } finally { + if (previous) Object.defineProperty(Object.prototype, "get", previous); + else delete (Object.prototype as Record).get; + } + + if (failure) throw failure; + assertEquals(snapshot, { + record: { enabled: true }, + values: ["safe"], + }); + }); + + it("rejects unsupported primitive and numeric values", () => { + for (const value of [undefined, 1n, Symbol("value"), () => undefined]) { + assertSnapshotError( + () => canonicalizeConfigSnapshot(value), + "unsupported-type", + ); + } + + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assertSnapshotError( + () => canonicalizeConfigSnapshot(value), + "non-finite-number", + ); + } + + assertSnapshotError( + () => canonicalizeConfigSnapshot({ missing: undefined }), + "unsupported-type", + ); + }); + + it("rejects custom prototypes and class instances", () => { + class CustomConfig { + readonly enabled = true; + } + + for ( + const value of [ + new CustomConfig(), + new Date(0), + Object.create({ inherited: true }), + ] + ) { + assertSnapshotError( + () => canonicalizeConfigSnapshot(value), + "invalid-prototype", + ); + } + + class CustomArray extends Array {} + assertSnapshotError( + () => canonicalizeConfigSnapshot(new CustomArray("value")), + "invalid-prototype", + ); + }); + + it("normalizes revoked proxies into the snapshot error contract", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + assertSnapshotError( + () => canonicalizeConfigSnapshot(proxy), + "inspection-failed", + ); + }); + + it("fails closed when proxy property-key inspection throws", () => { + let getterCalls = 0; + const target = Object.create(null) as Record; + Object.defineProperty(target, "value", { + configurable: true, + enumerable: true, + get() { + getterCalls += 1; + return "secret"; + }, + }); + const input = new Proxy(target, { + ownKeys() { + throw new Error("ownKeys failed"); + }, + }); + + const error = assertSnapshotError( + () => canonicalizeConfigSnapshot(input), + "inspection-failed", + ); + assertEquals(error.path, "$"); + assertEquals(getterCalls, 0); + }); + + it("fails closed when proxy descriptor inspection throws", () => { + let getterCalls = 0; + const target = Object.create(null) as Record; + Object.defineProperty(target, "value", { + configurable: true, + enumerable: true, + get() { + getterCalls += 1; + return "secret"; + }, + }); + const input = new Proxy(target, { + getOwnPropertyDescriptor() { + throw new Error("descriptor failed"); + }, + }); + + const error = assertSnapshotError( + () => canonicalizeConfigSnapshot(input), + "inspection-failed", + ); + assertEquals(error.path, '$["value"]'); + assertEquals(getterCalls, 0); + }); + + it("fails closed when a proxy mutates between key and descriptor inspection", () => { + const target = Object.create(null) as Record; + Object.defineProperty(target, "vanished", { + configurable: true, + enumerable: true, + value: "value", + }); + const input = new Proxy(target, { + ownKeys(value) { + const keys = Reflect.ownKeys(value); + delete value.vanished; + return keys; + }, + }); + + const error = assertSnapshotError( + () => canonicalizeConfigSnapshot(input), + "inspection-failed", + ); + assertEquals(error.path, '$["vanished"]'); + }); + + it("rejects cycles and shared object aliases", () => { + const cycle = Object.create(null) as Record; + cycle.self = cycle; + assertSnapshotError( + () => canonicalizeConfigSnapshot(cycle), + "duplicate-reference", + ); + + const shared = { enabled: true }; + assertSnapshotError( + () => canonicalizeConfigSnapshot({ first: shared, second: shared }), + "duplicate-reference", + ); + }); + + it("rejects sparse, extended, and accessor-backed arrays", () => { + const sparse = new Array(2); + sparse[0] = "value"; + assertSnapshotError( + () => canonicalizeConfigSnapshot(sparse), + "invalid-array-shape", + ); + + const extended = ["value"]; + Object.defineProperty(extended, "extra", { + value: true, + enumerable: true, + }); + assertSnapshotError( + () => canonicalizeConfigSnapshot(extended), + "invalid-array-shape", + ); + + let getterCalls = 0; + const accessorArray = new Array(1); + Object.defineProperty(accessorArray, "0", { + enumerable: true, + get() { + getterCalls += 1; + return "value"; + }, + }); + assertSnapshotError( + () => canonicalizeConfigSnapshot(accessorArray), + "accessor-property", + ); + assertEquals(getterCalls, 0); + }); + + it("rejects symbols, hidden properties, and pollution-prone keys", () => { + const symbolProperty = { safe: true }; + Object.defineProperty(symbolProperty, Symbol("hidden"), { + value: true, + enumerable: true, + }); + assertSnapshotError( + () => canonicalizeConfigSnapshot(symbolProperty), + "symbol-key", + ); + + const hiddenProperty = { safe: true }; + Object.defineProperty(hiddenProperty, "hidden", { + value: true, + enumerable: false, + }); + assertSnapshotError( + () => canonicalizeConfigSnapshot(hiddenProperty), + "non-enumerable-property", + ); + + for (const key of ["__proto__", "constructor", "prototype"]) { + const input = Object.create(null) as Record; + Object.defineProperty(input, key, { + value: true, + enumerable: true, + }); + assertSnapshotError( + () => canonicalizeConfigSnapshot(input), + "dangerous-key", + ); + } + }); + + it("uses canonical ECMAScript ordering for integer-like keys", () => { + const input = Object.create(null) as Record; + input["10"] = "ten"; + input["2"] = "two"; + input.alpha = "last"; + + const snapshot = asRecord(canonicalizeConfigSnapshot(input)); + assertEquals(Reflect.ownKeys(snapshot), ["2", "10", "alpha"]); + }); + + it("enforces depth, array, object, key, and string limits", () => { + let deeplyNested: unknown = null; + for (let index = 0; index <= CONFIG_SNAPSHOT_LIMITS.maxDepth; index += 1) { + deeplyNested = [deeplyNested]; + } + assertSnapshotError( + () => canonicalizeConfigSnapshot(deeplyNested), + "max-depth-exceeded", + ); + + const oversizedArray = new Array( + CONFIG_SNAPSHOT_LIMITS.maxArrayLength + 1, + ).fill(null); + assertSnapshotError( + () => canonicalizeConfigSnapshot(oversizedArray), + "max-array-length-exceeded", + ); + + const wideObject = Object.create(null) as Record; + for ( + let index = 0; + index <= CONFIG_SNAPSHOT_LIMITS.maxObjectKeys; + index += 1 + ) { + wideObject[`key-${index}`] = null; + } + assertSnapshotError( + () => canonicalizeConfigSnapshot(wideObject), + "max-object-keys-exceeded", + ); + + const longKeyInput = Object.create(null) as Record; + longKeyInput["k".repeat(CONFIG_SNAPSHOT_LIMITS.maxKeyLength + 1)] = true; + assertSnapshotError( + () => canonicalizeConfigSnapshot(longKeyInput), + "max-key-length-exceeded", + ); + + const oversizedString = "s".repeat( + CONFIG_SNAPSHOT_LIMITS.maxStringLength + 1, + ); + assertSnapshotError( + () => canonicalizeConfigSnapshot(oversizedString), + "max-string-length-exceeded", + ); + }); + + it("bounds total value and property traversal", () => { + const nodeHeavy = new Array(); + for (let index = 0; index < 6; index += 1) { + nodeHeavy.push( + new Array(CONFIG_SNAPSHOT_LIMITS.maxArrayLength).fill(null), + ); + } + assertSnapshotError( + () => canonicalizeConfigSnapshot(nodeHeavy), + "max-nodes-exceeded", + ); + + let propertyHeavy: unknown = null; + for (let index = 0; index < 9; index += 1) { + const layer = new Array( + CONFIG_SNAPSHOT_LIMITS.maxArrayLength, + ).fill(null); + layer[0] = propertyHeavy; + propertyHeavy = layer; + } + assertSnapshotError( + () => canonicalizeConfigSnapshot(propertyHeavy), + "max-properties-exceeded", + ); + }); + + it("bounds the conservative serialized-size estimate", () => { + const maximumString = "s".repeat(CONFIG_SNAPSHOT_LIMITS.maxStringLength); + assertSnapshotError( + () => + canonicalizeConfigSnapshot([ + maximumString, + maximumString, + maximumString, + ]), + "max-estimated-bytes-exceeded", + ); + }); + + it("keeps the fixed security limits immutable", () => { + assertEquals(Object.isFrozen(CONFIG_SNAPSHOT_LIMITS), true); + assertThrows( + () => + Object.defineProperty(CONFIG_SNAPSHOT_LIMITS, "maxDepth", { + value: Number.MAX_SAFE_INTEGER, + }), + TypeError, + ); + }); +}); diff --git a/src/config/snapshot.ts b/src/config/snapshot.ts new file mode 100644 index 0000000000..5f93742275 --- /dev/null +++ b/src/config/snapshot.ts @@ -0,0 +1,529 @@ +/** + * Creates bounded, immutable plain-data snapshots at configuration trust + * boundaries. + * + * Traversal uses property descriptors exclusively. Accessor properties are + * rejected without invoking their getters, and object prototypes are never + * inherited by the returned snapshot. + * + * @module + */ + +const IntrinsicArray = Array; +const IntrinsicWeakSet = WeakSet; +const ArrayIsArray = Array.isArray; +const ArrayPrototype = Array.prototype; +const ArrayPrototypeSort = Array.prototype.sort; +const JSONStringify = JSON.stringify; +const MathMax = Math.max; +const NumberIsFinite = Number.isFinite; +const NumberIsSafeInteger = Number.isSafeInteger; +const ObjectCreate = Object.create; +const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectPrototype = Object.prototype; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectOwnKeys = Reflect.ownKeys; +const WeakSetPrototypeAdd = WeakSet.prototype.add; +const WeakSetPrototypeHas = WeakSet.prototype.has; + +const ARRAY_OR_OBJECT_BYTES = 2; +const ARRAY_ITEM_SEPARATOR_BYTES = 1; +const JSON_NUMBER_MAX_BYTES = 24; +const JSON_STRING_DELIMITER_BYTES = 2; +const JSON_STRING_MAX_BYTES_PER_CODE_UNIT = 6; +const OBJECT_PROPERTY_SEPARATOR_BYTES = 2; + +/** + * Resource limits for a canonical configuration snapshot. + * + * `maxEstimatedBytes` is a conservative upper bound for UTF-8 JSON output: + * every string code unit is charged at the longest JSON escape length. + */ +export interface ConfigSnapshotLimits { + readonly maxDepth: number; + readonly maxTotalNodes: number; + readonly maxTotalProperties: number; + readonly maxArrayLength: number; + readonly maxObjectKeys: number; + readonly maxKeyLength: number; + readonly maxStringLength: number; + readonly maxEstimatedBytes: number; +} + +/** Fixed limits applied to every configuration snapshot. */ +export const CONFIG_SNAPSHOT_LIMITS: Readonly = ObjectFreeze({ + maxDepth: 32, + maxTotalNodes: 12_288, + maxTotalProperties: 16_384, + maxArrayLength: 2_048, + maxObjectKeys: 1_024, + maxKeyLength: 1_024, + maxStringLength: 65_536, + maxEstimatedBytes: 1_048_576, +}); + +/** Primitive values supported by a configuration snapshot. */ +export type ConfigSnapshotPrimitive = null | boolean | number | string; + +/** A deeply immutable, null-prototype configuration record. */ +export interface ConfigSnapshotRecord { + readonly [key: string]: ConfigSnapshotValue; +} + +/** A value accepted and returned by {@link canonicalizeConfigSnapshot}. */ +export type ConfigSnapshotValue = + | ConfigSnapshotPrimitive + | ConfigSnapshotRecord + | readonly ConfigSnapshotValue[]; + +/** Stable machine-readable reasons for snapshot rejection. */ +export type ConfigSnapshotErrorCode = + | "accessor-property" + | "dangerous-key" + | "duplicate-reference" + | "inspection-failed" + | "invalid-array-shape" + | "invalid-prototype" + | "max-array-length-exceeded" + | "max-depth-exceeded" + | "max-estimated-bytes-exceeded" + | "max-key-length-exceeded" + | "max-nodes-exceeded" + | "max-object-keys-exceeded" + | "max-properties-exceeded" + | "max-string-length-exceeded" + | "non-enumerable-property" + | "non-finite-number" + | "symbol-key" + | "unsupported-type"; + +/** Error raised when a value cannot be represented as a safe snapshot. */ +export class ConfigSnapshotError extends TypeError { + readonly code: ConfigSnapshotErrorCode; + readonly path: string; + + constructor(code: ConfigSnapshotErrorCode, path: string, reason: string) { + super(`Invalid configuration snapshot at ${path}: ${reason}`); + this.name = "ConfigSnapshotError"; + this.code = code; + this.path = path; + } +} + +interface SnapshotState { + readonly seen: WeakSet; + nodeCount: number; + propertyCount: number; + estimatedBytes: number; +} + +function reject( + code: ConfigSnapshotErrorCode, + path: string, + reason: string, +): never { + throw new ConfigSnapshotError(code, path, reason); +} + +function addNode(state: SnapshotState, path: string): void { + state.nodeCount += 1; + if (state.nodeCount > CONFIG_SNAPSHOT_LIMITS.maxTotalNodes) { + reject( + "max-nodes-exceeded", + path, + `value count exceeds ${CONFIG_SNAPSHOT_LIMITS.maxTotalNodes}`, + ); + } +} + +function addProperties(state: SnapshotState, count: number, path: string): void { + if (count > CONFIG_SNAPSHOT_LIMITS.maxTotalProperties - state.propertyCount) { + reject( + "max-properties-exceeded", + path, + `property count exceeds ${CONFIG_SNAPSHOT_LIMITS.maxTotalProperties}`, + ); + } + state.propertyCount += count; +} + +function addEstimatedBytes(state: SnapshotState, count: number, path: string): void { + if (count > CONFIG_SNAPSHOT_LIMITS.maxEstimatedBytes - state.estimatedBytes) { + reject( + "max-estimated-bytes-exceeded", + path, + `estimated serialized size exceeds ${CONFIG_SNAPSHOT_LIMITS.maxEstimatedBytes} bytes`, + ); + } + state.estimatedBytes += count; +} + +function estimateJsonStringBytes(length: number): number { + return JSON_STRING_DELIMITER_BYTES + length * JSON_STRING_MAX_BYTES_PER_CODE_UNIT; +} + +function childPath(path: string, key: string): string { + return `${path}[${JSONStringify(key)}]`; +} + +function arrayChildPath(path: string, index: number): string { + return `${path}[${index}]`; +} + +function inspectPrototype(value: object, path: string): object | null { + try { + return ObjectGetPrototypeOf(value); + } catch { + return reject("inspection-failed", path, "prototype inspection failed"); + } +} + +function inspectIsArray(value: object, path: string): value is unknown[] { + try { + return ArrayIsArray(value); + } catch { + return reject("inspection-failed", path, "array inspection failed"); + } +} + +function inspectOwnKeys(value: object, path: string): PropertyKey[] { + try { + return ReflectOwnKeys(value); + } catch { + return reject("inspection-failed", path, "property-key inspection failed"); + } +} + +function inspectDescriptor( + value: object, + key: PropertyKey, + path: string, +): PropertyDescriptor { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = ObjectGetOwnPropertyDescriptor(value, key); + } catch { + return reject("inspection-failed", path, "property descriptor inspection failed"); + } + + if (descriptor === undefined) { + return reject("inspection-failed", path, "property changed during inspection"); + } + return descriptor; +} + +function hasDescriptorValue(descriptor: PropertyDescriptor): boolean { + return ReflectApply( + ObjectPrototypeHasOwnProperty, + descriptor, + ["value"], + ) as boolean; +} + +function descriptorValue(descriptor: PropertyDescriptor, path: string): unknown { + if (!hasDescriptorValue(descriptor)) { + return reject("accessor-property", path, "accessor properties are not allowed"); + } + if (!descriptor.enumerable) { + return reject( + "non-enumerable-property", + path, + "non-enumerable properties are not allowed", + ); + } + return descriptor.value; +} + +function hasSeen(state: SnapshotState, value: object): boolean { + return ReflectApply(WeakSetPrototypeHas, state.seen, [value]) as boolean; +} + +function markSeen(state: SnapshotState, value: object): void { + ReflectApply(WeakSetPrototypeAdd, state.seen, [value]); +} + +function toArrayIndex(key: string): number | null { + if (key === "") return null; + const value = +key; + return NumberIsSafeInteger(value) && + value >= 0 && + value <= 4_294_967_294 && + `${value}` === key + ? value + : null; +} + +function compareCanonicalKeys(left: string, right: string): number { + const leftIndex = toArrayIndex(left); + const rightIndex = toArrayIndex(right); + if (leftIndex !== null && rightIndex !== null) return leftIndex - rightIndex; + if (leftIndex !== null) return -1; + if (rightIndex !== null) return 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +function isDangerousKey(key: string): boolean { + return key === "__proto__" || key === "constructor" || key === "prototype"; +} + +function defineDataProperty( + target: object, + key: PropertyKey, + value: unknown, + enumerable: boolean, + writable: boolean, + configurable: boolean, +): void { + const descriptor = ObjectCreate(null) as PropertyDescriptor; + descriptor.value = value; + descriptor.enumerable = enumerable; + descriptor.writable = writable; + descriptor.configurable = configurable; + ObjectDefineProperty(target, key, descriptor); +} + +function canonicalizeArray( + value: unknown[], + path: string, + depth: number, + state: SnapshotState, +): readonly ConfigSnapshotValue[] { + if (inspectPrototype(value, path) !== ArrayPrototype) { + return reject("invalid-prototype", path, "array subclasses are not allowed"); + } + + const lengthDescriptor = inspectDescriptor(value, "length", path); + if (!hasDescriptorValue(lengthDescriptor)) { + return reject("invalid-array-shape", path, "array length is invalid"); + } + const lengthValue = lengthDescriptor.value; + if ( + typeof lengthValue !== "number" || + !NumberIsSafeInteger(lengthValue) || + lengthValue < 0 + ) { + return reject("invalid-array-shape", path, "array length is invalid"); + } + if (lengthValue > CONFIG_SNAPSHOT_LIMITS.maxArrayLength) { + return reject( + "max-array-length-exceeded", + path, + `array length exceeds ${CONFIG_SNAPSHOT_LIMITS.maxArrayLength}`, + ); + } + + addProperties(state, lengthValue, path); + addEstimatedBytes( + state, + ARRAY_OR_OBJECT_BYTES + MathMax(0, lengthValue - 1) * ARRAY_ITEM_SEPARATOR_BYTES, + path, + ); + + const keys = inspectOwnKeys(value, path); + for (let index = 0; index < keys.length; index += 1) { + if (typeof keys[index] === "symbol") { + return reject("symbol-key", path, "symbol properties are not allowed"); + } + } + + if (keys.length !== lengthValue + 1) { + return reject( + "invalid-array-shape", + path, + "arrays must be dense and cannot have extra own properties", + ); + } + + const output = new IntrinsicArray(lengthValue); + for (let index = 0; index < lengthValue; index += 1) { + const indexPath = arrayChildPath(path, index); + if (keys[index] !== `${index}`) { + return reject( + "invalid-array-shape", + indexPath, + "arrays must be dense and cannot have extra own properties", + ); + } + + const descriptor = inspectDescriptor(value, `${index}`, indexPath); + const child = canonicalizeValue( + descriptorValue(descriptor, indexPath), + indexPath, + depth + 1, + state, + ); + defineDataProperty(output, index, child, true, true, true); + } + + if (keys[lengthValue] !== "length") { + return reject("invalid-array-shape", path, "array length metadata is invalid"); + } + return ObjectFreeze(output); +} + +function canonicalizeRecord( + value: object, + path: string, + depth: number, + state: SnapshotState, +): ConfigSnapshotRecord { + const prototype = inspectPrototype(value, path); + if (prototype !== null && prototype !== ObjectPrototype) { + return reject( + "invalid-prototype", + path, + "only plain or null-prototype records are allowed", + ); + } + + const ownKeys = inspectOwnKeys(value, path); + if (ownKeys.length > CONFIG_SNAPSHOT_LIMITS.maxObjectKeys) { + return reject( + "max-object-keys-exceeded", + path, + `object key count exceeds ${CONFIG_SNAPSHOT_LIMITS.maxObjectKeys}`, + ); + } + + addProperties(state, ownKeys.length, path); + addEstimatedBytes( + state, + ARRAY_OR_OBJECT_BYTES + + MathMax(0, ownKeys.length - 1) * OBJECT_PROPERTY_SEPARATOR_BYTES, + path, + ); + + const keys = new IntrinsicArray(ownKeys.length); + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + if (typeof key !== "string") { + return reject("symbol-key", path, "symbol properties are not allowed"); + } + if (key.length > CONFIG_SNAPSHOT_LIMITS.maxKeyLength) { + return reject( + "max-key-length-exceeded", + path, + `object key length exceeds ${CONFIG_SNAPSHOT_LIMITS.maxKeyLength}`, + ); + } + if (isDangerousKey(key)) { + return reject( + "dangerous-key", + childPath(path, key), + `property name ${JSONStringify(key)} is not allowed`, + ); + } + + addEstimatedBytes( + state, + estimateJsonStringBytes(key.length) + OBJECT_PROPERTY_SEPARATOR_BYTES, + path, + ); + defineDataProperty(keys, index, key, true, true, true); + } + ReflectApply(ArrayPrototypeSort, keys, [compareCanonicalKeys]); + + const output = ObjectCreate(null) as Record; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + const keyPath = childPath(path, key); + const descriptor = inspectDescriptor(value, key, keyPath); + const child = canonicalizeValue( + descriptorValue(descriptor, keyPath), + keyPath, + depth + 1, + state, + ); + defineDataProperty(output, key, child, true, false, false); + } + return ObjectFreeze(output); +} + +function canonicalizeValue( + value: unknown, + path: string, + depth: number, + state: SnapshotState, +): ConfigSnapshotValue { + if (depth > CONFIG_SNAPSHOT_LIMITS.maxDepth) { + return reject( + "max-depth-exceeded", + path, + `nesting depth exceeds ${CONFIG_SNAPSHOT_LIMITS.maxDepth}`, + ); + } + addNode(state, path); + + if (value === null) { + addEstimatedBytes(state, 4, path); + return null; + } + + switch (typeof value) { + case "boolean": + addEstimatedBytes(state, 5, path); + return value; + case "number": + if (!NumberIsFinite(value)) { + return reject("non-finite-number", path, "numbers must be finite"); + } + addEstimatedBytes(state, JSON_NUMBER_MAX_BYTES, path); + return value; + case "string": + if (value.length > CONFIG_SNAPSHOT_LIMITS.maxStringLength) { + return reject( + "max-string-length-exceeded", + path, + `string length exceeds ${CONFIG_SNAPSHOT_LIMITS.maxStringLength}`, + ); + } + addEstimatedBytes(state, estimateJsonStringBytes(value.length), path); + return value; + case "object": + if (hasSeen(state, value)) { + return reject( + "duplicate-reference", + path, + "cycles and shared object references are not allowed", + ); + } + markSeen(state, value); + return inspectIsArray(value, path) + ? canonicalizeArray(value, path, depth, state) + : canonicalizeRecord(value, path, depth, state); + default: + return reject( + "unsupported-type", + path, + `${typeof value} values are not allowed`, + ); + } +} + +/** + * Returns a detached, deeply frozen configuration snapshot. + * + * Records are rebuilt with null prototypes and canonical ECMAScript own-key + * order: integer indices numerically, followed by other keys lexicographically. + * Arrays retain the intrinsic array prototype for existing collection, JSON, + * and structured-clone consumers, but their own state is rebuilt densely and + * deeply frozen. + * + * Actively hostile values should reach this function through a non-executable + * decoding boundary such as JSON parsing. JavaScript cannot reliably identify + * proxies, whose reflection traps can execute during any descriptor walk. + * + * @throws {ConfigSnapshotError} when the input is not bounded plain data. + */ +export function canonicalizeConfigSnapshot(value: unknown): ConfigSnapshotValue { + return canonicalizeValue(value, "$", 0, { + seen: new IntrinsicWeakSet(), + nodeCount: 0, + propertyCount: 0, + estimatedBytes: 0, + }); +} diff --git a/src/integrations/limits.ts b/src/integrations/limits.ts new file mode 100644 index 0000000000..afcb519e25 --- /dev/null +++ b/src/integrations/limits.ts @@ -0,0 +1,56 @@ +/** + * Resource ceilings for the remote integration bridge. + * + * Keep these limits independent of agent, worker, and provider modules so the + * integration boundary does not acquire an inverted dependency on one of its + * consumers. The values are deliberately generous for existing integrations + * while still placing deterministic bounds on authenticated remote input. + */ + +/** End-to-end deadline for one integration API request, including body reads. */ +export const INTEGRATION_REQUEST_TIMEOUT_MS = 30_000; + +/** Maximum serialized request accepted by the remote tool-call endpoint. */ +export const MAX_INTEGRATION_CALL_REQUEST_BYTES = 4 * 1024 * 1024; + +/** Maximum decoded JSON response accepted from tool discovery. */ +export const MAX_INTEGRATION_TOOL_LIST_RESPONSE_BYTES = 16 * 1024 * 1024; + +/** Maximum decoded JSON response accepted from tool execution. */ +export const MAX_INTEGRATION_TOOL_CALL_RESPONSE_BYTES = 4 * 1024 * 1024; + +/** Maximum diagnostic prefix retained from a failed integration API response. */ +export const MAX_INTEGRATION_API_ERROR_RESPONSE_BYTES = 4 * 1024; + +/** Maximum number of remote tool definitions admitted atomically. */ +export const MAX_REMOTE_INTEGRATION_TOOL_DEFINITIONS = 1_000; + +/** Maximum caller or environment credential length admitted into an HTTP header. */ +export const MAX_REMOTE_INTEGRATION_API_TOKEN_LENGTH = 16_384; + +/** Runtime tool names use the same ceiling as the agent invocation contract. */ +export const MAX_REMOTE_INTEGRATION_TOOL_NAME_LENGTH = 128; + +/** Maximum canonical connector-name length at lookup and policy boundaries. */ +export const MAX_INTEGRATION_NAME_LENGTH = MAX_REMOTE_INTEGRATION_TOOL_NAME_LENGTH - 3; + +/** Maximum integrations admitted into one exact-source narrowing policy. */ +export const MAX_SOURCE_INTEGRATION_POLICY_INTEGRATIONS = 512; + +/** A segment must leave room for the separator and the other non-empty segment. */ +export const MAX_SOURCE_INTEGRATION_POLICY_SEGMENT_LENGTH = MAX_INTEGRATION_NAME_LENGTH; + +/** A policy cannot usefully name more tools than remote discovery can admit. */ +export const MAX_SOURCE_INTEGRATION_POLICY_TOOL_IDS = MAX_REMOTE_INTEGRATION_TOOL_DEFINITIONS; + +/** Runtime tool descriptions use the same ceiling as the agent invocation contract. */ +export const MAX_REMOTE_INTEGRATION_TOOL_DESCRIPTION_LENGTH = 1_024; + +/** Run and agent identifiers use the same ceiling as the agent invocation contract. */ +export const MAX_REMOTE_INTEGRATION_CONTEXT_ID_LENGTH = 128; + +/** Runtime tool schemas use the same serialized ceiling as the agent invocation contract. */ +export const MAX_REMOTE_INTEGRATION_TOOL_SCHEMA_BYTES = 16_384; + +/** Prevent recursively consumed provider schemas from approaching stack limits. */ +export const MAX_REMOTE_INTEGRATION_TOOL_SCHEMA_DEPTH = 64; diff --git a/src/schemas/README.md b/src/schemas/README.md index cdf396718d..d2334bc958 100644 --- a/src/schemas/README.md +++ b/src/schemas/README.md @@ -1,10 +1,10 @@ # Schemas module -This directory contains shared validation schemas used across multiple modules in the veryfront codebase. +This directory contains shared validation schemas used across multiple modules in the Veryfront codebase. ## Architecture -The veryfront codebase follows a **schema-first approach** where: +The Veryfront codebase follows a **schema-first approach** where: 1. **`defineSchema` schemas are the single source of truth** for types 2. **TypeScript types are inferred** from schemas using `InferSchema>` @@ -16,7 +16,9 @@ The veryfront codebase follows a **schema-first approach** where: - **Schema files**: `{name}.schema.ts` (e.g., `config.schema.ts`) - **Shared schema files**: `common.ts`, `primitives.ts` (no `.schema` suffix since they're collections) - **Schema getters**: Use `get` + PascalCase (e.g., `getUserSchema`) -- **Schema exports**: Backward-compat constant (e.g., `export const UserSchema = getUserSchema()`) +- **Schema exports**: Compatibility constants use `lazySchema` (e.g., + `export const UserSchema = lazySchema(getUserSchema)`) so importing a module + does not require a registered `SchemaValidator` - **Type exports**: Infer types from schema getters (e.g., `type User = InferSchema>`) ## Directory structure @@ -125,9 +127,8 @@ unbounded document. ```typescript // schemas/user.schema.ts -import { defineSchema } from "#veryfront/schemas/index.ts"; +import { CommonSchemas, defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; -import { CommonSchemas } from "#veryfront/schemas"; export const getUserSchema = defineSchema((v) => v.object({ @@ -137,7 +138,7 @@ export const getUserSchema = defineSchema((v) => createdAt: v.string().datetime(), }) ); -export const UserSchema = getUserSchema(); +export const UserSchema = lazySchema(getUserSchema); export type User = InferSchema>; ``` @@ -146,7 +147,7 @@ export type User = InferSchema>; ```typescript // schemas/events.schema.ts -import { defineSchema } from "#veryfront/schemas/index.ts"; +import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; export const getEventSchema = defineSchema((v) => @@ -162,7 +163,7 @@ export const getEventSchema = defineSchema((v) => }), ]) ); -export const EventSchema = getEventSchema(); +export const EventSchema = lazySchema(getEventSchema); export type Event = InferSchema>; ``` @@ -171,7 +172,7 @@ export type Event = InferSchema>; ```typescript // schemas/api.schema.ts -import { defineSchema } from "#veryfront/schemas/index.ts"; +import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; const getBaseResponseSchema = defineSchema((v) => @@ -204,7 +205,7 @@ export const getApiResponseSchema = defineSchema((v) => getErrorResponseSchema(), ]) ); -export const ApiResponseSchema = getApiResponseSchema(); +export const ApiResponseSchema = lazySchema(getApiResponseSchema); export type ApiResponse = InferSchema>; ``` @@ -213,7 +214,7 @@ export type ApiResponse = InferSchema>; ```typescript // schemas/tree.schema.ts -import { defineSchema } from "#veryfront/schemas/index.ts"; +import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema, Schema } from "#veryfront/extensions/schema/index.ts"; export const getTreeNodeSchema = defineSchema((v) => { @@ -225,16 +226,21 @@ export const getTreeNodeSchema = defineSchema((v) => { ); return schema; }); -export const TreeNodeSchema = getTreeNodeSchema(); +export const TreeNodeSchema = lazySchema(getTreeNodeSchema); export type TreeNode = InferSchema>; ``` ### 5. Runtime validation +Calling a schema getter directly materializes the schema. Use direct getter +invocation only after application or extension bootstrap has registered a +`SchemaValidator`. Use `lazySchema(getUserSchema)` for module-scope exports. + ```typescript import { getUserSchema } from "./schemas/user.schema.ts"; +// This code runs after SchemaValidator registration. const UserSchema = getUserSchema(); function createUser(data: unknown) { @@ -284,7 +290,7 @@ export interface User { ```typescript // schemas/user.schema.ts -import { defineSchema } from "#veryfront/schemas/index.ts"; +import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; export const getUserSchema = defineSchema((v) => @@ -294,7 +300,7 @@ export const getUserSchema = defineSchema((v) => name: v.string().min(1), }) ); -export const UserSchema = getUserSchema(); +export const UserSchema = lazySchema(getUserSchema); export type User = InferSchema>; ``` @@ -311,10 +317,13 @@ export type User = InferSchema>; ## Testing schemas +The `_test-setup.ts` side-effect import registers the test validator before the +schema getter runs, so direct getter invocation is safe in this example. + ```typescript import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { expect } from "#std/expect"; import { getUserSchema } from "./user.schema.ts"; const UserSchema = getUserSchema(); @@ -327,7 +336,7 @@ describe("UserSchema", () => { name: "John Doe", }); - expect(result.success).toBe(true); + assertEquals(result.success, true); }); it("rejects invalid email", () => { @@ -337,7 +346,7 @@ describe("UserSchema", () => { name: "John Doe", }); - expect(result.success).toBe(false); + assertEquals(result.success, false); }); }); ``` diff --git a/src/schemas/_test-setup.ts b/src/schemas/_test-setup.ts index 35957cd715..9e0fc1994c 100644 --- a/src/schemas/_test-setup.ts +++ b/src/schemas/_test-setup.ts @@ -13,6 +13,10 @@ import { register, tryResolve } from "#veryfront/extensions/contracts.ts"; import type { SchemaValidator } from "#veryfront/extensions/schema/index.ts"; import { createZodAdapter } from "../../extensions/ext-schema-zod/src/adapter.ts"; -if (!tryResolve("SchemaValidator")) { - register("SchemaValidator", createZodAdapter()); +export function ensureTestSchemaValidator(): void { + if (!tryResolve("SchemaValidator")) { + register("SchemaValidator", createZodAdapter()); + } } + +ensureTestSchemaValidator(); diff --git a/src/schemas/common.test.ts b/src/schemas/common.test.ts index b4dd098fdd..9969f4eb34 100644 --- a/src/schemas/common.test.ts +++ b/src/schemas/common.test.ts @@ -153,6 +153,13 @@ describe("CommonSchemas", () => { it("should reject invalid order values", () => { assertParseFailure(CommonSchemas.pagination.safeParse({ order: "random" })); }); + + it("should reject non-string and non-number pagination values", () => { + assertParseFailure(CommonSchemas.pagination.safeParse({ page: true })); + assertParseFailure(CommonSchemas.pagination.safeParse({ page: ["3"] })); + assertParseFailure(CommonSchemas.pagination.safeParse({ limit: true })); + assertParseFailure(CommonSchemas.pagination.safeParse({ limit: ["20"] })); + }); }); describe("dateRange", () => { diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 11aeaf5d15..73efe5a74f 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -48,7 +48,7 @@ export const getPaginationSchema = defineSchema((v) => { .pipe(numberSchema), numberSchema, ]); - const pageNumber = v.number().int().positive().max(Number.MAX_SAFE_INTEGER); + const pageNumber = v.number().int().positive(); const pageLimit = v.number().int().positive().max(MAX_PAGE_LIMIT); return v.object({ diff --git a/src/schemas/define.test.ts b/src/schemas/define.test.ts index 6e1cd7bb38..0605d9609a 100644 --- a/src/schemas/define.test.ts +++ b/src/schemas/define.test.ts @@ -5,6 +5,7 @@ import { register, reset, tryResolve } from "#veryfront/extensions/contracts.ts" import type { JsonSchema, Schema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; import { defineSchema } from "./define.ts"; import { compileJsonSchemaValidator, tryCompileJsonSchemaValidator } from "./json-schema.ts"; +import { createRuntimeJsonSchema } from "#veryfront/agent/runtime/runtime-tool-builder.ts"; import { lazySchema } from "./lazy.ts"; import { createZodAdapter } from "../../extensions/ext-schema-zod/src/adapter.ts"; @@ -238,4 +239,15 @@ describe("defineSchema", () => { assertEquals(descriptorReads, 1); assertEquals(valueReads, 0); }); + + it("omits optional runtime validation when raw-schema compilation is unavailable", () => { + reset(); + const { compileJsonSchema: _unsupported, ...legacyAdapter } = createZodAdapter(); + register("SchemaValidator", legacyAdapter); + + const runtimeSchema = createRuntimeJsonSchema({ type: "object" }); + + assertEquals(runtimeSchema.jsonSchema, { type: "object" }); + assertEquals(runtimeSchema.validate, undefined); + }); }); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index acf97c84f7..3b2ad86b2c 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,11 +1,13 @@ /** * Reusable validation schemas and the `defineSchema` helper. * + * Schema materialization requires a registered `SchemaValidator`. Veryfront + * runtime bootstrap registers the built-in validator before handlers run. + * `lazySchema` keeps module-scope schema constants import-safe before bootstrap. + * * @example * ```ts - * import { CommonSchemas, defineSchema } from "veryfront/schemas"; - * - * const email = CommonSchemas.email.parse("user@example.com"); + * import { CommonSchemas, defineSchema, lazySchema } from "veryfront/schemas"; * * const getUserSchema = defineSchema((v) => * v.object({ @@ -13,6 +15,11 @@ * name: v.string().min(1), * }) * ); + * export const UserSchema = lazySchema(getUserSchema); + * + * export function parseEmail(input: unknown) { + * return CommonSchemas.email.parse(input); + * } * ``` * * @module schemas diff --git a/src/schemas/json-value.ts b/src/schemas/json-value.ts index b8c99863c1..3831f85909 100644 --- a/src/schemas/json-value.ts +++ b/src/schemas/json-value.ts @@ -22,14 +22,25 @@ export type BoundedJsonValue = | BoundedJsonValue[] | { [key: string]: BoundedJsonValue }; +export type BoundedJsonPathSegment = string | number; + export type BoundedJsonSnapshot = | { success: true; value: BoundedJsonValue } - | { success: false }; + | { success: false; path: readonly BoundedJsonPathSegment[] }; type CanonicalJsonContainer = | BoundedJsonValue[] | { [key: string]: BoundedJsonValue }; +interface SnapshotPathNode { + parent: SnapshotPathNode | undefined; + segment: BoundedJsonPathSegment; +} + +interface InvalidSnapshotPath { + path: SnapshotPathNode | undefined; +} + type SnapshotFrame = | { kind: "visit"; @@ -37,12 +48,33 @@ type SnapshotFrame = depth: number; parent?: CanonicalJsonContainer; key?: string | number; + path: SnapshotPathNode | undefined; } | { kind: "exit"; value: object }; type SnapshotVisitFrame = Extract; -const INVALID_JSON_SNAPSHOT: BoundedJsonSnapshot = Object.freeze({ success: false }); +function appendSnapshotPath( + parent: SnapshotPathNode | undefined, + segment: BoundedJsonPathSegment, +): SnapshotPathNode { + return { parent, segment }; +} + +function invalidSnapshotPath(path: SnapshotPathNode | undefined): InvalidSnapshotPath { + return { path }; +} + +function invalidJsonSnapshot( + path: SnapshotPathNode | undefined, +): BoundedJsonSnapshot { + const segments: BoundedJsonPathSegment[] = []; + for (let current = path; current !== undefined; current = current.parent) { + segments.push(current.segment); + } + segments.reverse(); + return { success: false, path: segments }; +} function utf8LengthWithin(value: string, limit: number): number | undefined { if (value.length > limit) return undefined; @@ -63,12 +95,20 @@ function serializedByteLength(value: string | number | boolean | null): number | * and inputs above the documented depth, node, string, key, and * serialized-size limits. Only property descriptor values captured during * this walk are copied, so a stateful Proxy cannot change the value between - * validation and later consumption. + * validation and later consumption. Rejections include the path to the + * narrowest invalid value that could be identified without invoking caller + * code. */ export function snapshotBoundedJsonValue(value: unknown): BoundedJsonSnapshot { + let activePath: SnapshotPathNode | undefined; try { const activeAncestors = new Set(); - const stack: SnapshotFrame[] = [{ kind: "visit", value, depth: 0 }]; + const stack: SnapshotFrame[] = [{ + kind: "visit", + value, + depth: 0, + path: undefined, + }]; let nodeCount = 0; let serializedBytes = 0; let canonicalRoot: BoundedJsonValue | undefined; @@ -99,75 +139,76 @@ export function snapshotBoundedJsonValue(value: unknown): BoundedJsonSnapshot { activeAncestors.delete(frame.value); continue; } + activePath = frame.path; if (frame.depth > JSON_VALUE_MAX_DEPTH || ++nodeCount > JSON_VALUE_MAX_NODES) { - return INVALID_JSON_SNAPSHOT; + return invalidJsonSnapshot(frame.path); } const current = frame.value; if (current === null || typeof current === "boolean") { const bytes = serializedByteLength(current); - if (bytes === undefined || !addSerializedBytes(bytes)) return INVALID_JSON_SNAPSHOT; + if (bytes === undefined || !addSerializedBytes(bytes)) { + return invalidJsonSnapshot(frame.path); + } assign(frame, current); continue; } if (typeof current === "string") { if (utf8LengthWithin(current, JSON_VALUE_MAX_STRING_BYTES) === undefined) { - return INVALID_JSON_SNAPSHOT; + return invalidJsonSnapshot(frame.path); } const bytes = serializedByteLength(current); - if (bytes === undefined || !addSerializedBytes(bytes)) return INVALID_JSON_SNAPSHOT; + if (bytes === undefined || !addSerializedBytes(bytes)) { + return invalidJsonSnapshot(frame.path); + } assign(frame, current); continue; } if (typeof current === "number") { - if (!Number.isFinite(current)) return INVALID_JSON_SNAPSHOT; + if (!Number.isFinite(current)) return invalidJsonSnapshot(frame.path); const bytes = serializedByteLength(current); - if (bytes === undefined || !addSerializedBytes(bytes)) return INVALID_JSON_SNAPSHOT; + if (bytes === undefined || !addSerializedBytes(bytes)) { + return invalidJsonSnapshot(frame.path); + } assign(frame, current); continue; } if (typeof current !== "object" || activeAncestors.has(current)) { - return INVALID_JSON_SNAPSHOT; + return invalidJsonSnapshot(frame.path); } if (Array.isArray(current)) { - if ( - !snapshotArray( - current, - frame, - stack, - activeAncestors, - addSerializedBytes, - assign, - ) - ) { - return INVALID_JSON_SNAPSHOT; - } - continue; - } - - if ( - !snapshotObject( + const invalidPath = snapshotArray( current, frame, stack, activeAncestors, addSerializedBytes, assign, - ) - ) { - return INVALID_JSON_SNAPSHOT; + ); + if (invalidPath !== null) return invalidJsonSnapshot(invalidPath.path); + continue; } + + const invalidPath = snapshotObject( + current, + frame, + stack, + activeAncestors, + addSerializedBytes, + assign, + ); + if (invalidPath !== null) return invalidJsonSnapshot(invalidPath.path); } return rootAssigned ? { success: true, value: canonicalRoot as BoundedJsonValue } - : INVALID_JSON_SNAPSHOT; + : invalidJsonSnapshot(undefined); } catch { // Proxy traps and reflective operations can throw. Such values are not // data-only JSON inputs and must fail validation rather than escape it. - return INVALID_JSON_SNAPSHOT; + return invalidJsonSnapshot(activePath); } } @@ -178,7 +219,7 @@ function snapshotArray( activeAncestors: Set, addSerializedBytes: (amount: number) => boolean, assign: (frame: SnapshotVisitFrame, canonical: BoundedJsonValue) => void, -): boolean { +): InvalidSnapshotPath | null { const ownKeys = Reflect.ownKeys(value); const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, "length"); const length = lengthDescriptor && "value" in lengthDescriptor @@ -194,13 +235,15 @@ function snapshotArray( !ownKeys.includes("length") || !addSerializedBytes(2 + Math.max(0, length - 1)) ) { - return false; + return invalidSnapshotPath(frame.path); } const values: unknown[] = []; for (let index = 0; index < length; index++) { const descriptor = Reflect.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) return false; + if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) { + return invalidSnapshotPath(appendSnapshotPath(frame.path, index)); + } values.push(descriptor.value); } @@ -215,9 +258,10 @@ function snapshotArray( depth: frame.depth + 1, parent: canonical, key: index, + path: appendSnapshotPath(frame.path, index), }); } - return true; + return null; } function snapshotObject( @@ -227,9 +271,11 @@ function snapshotObject( activeAncestors: Set, addSerializedBytes: (amount: number) => boolean, assign: (frame: SnapshotVisitFrame, canonical: BoundedJsonValue) => void, -): boolean { +): InvalidSnapshotPath | null { const prototype = Reflect.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return false; + if (prototype !== Object.prototype && prototype !== null) { + return invalidSnapshotPath(frame.path); + } const ownKeys = Reflect.ownKeys(value); if ( @@ -237,17 +283,24 @@ function snapshotObject( ownKeys.some((key) => typeof key === "symbol") || !addSerializedBytes(2 + Math.max(0, ownKeys.length - 1)) ) { - return false; + return invalidSnapshotPath(frame.path); } const values: unknown[] = []; for (const key of ownKeys as string[]) { - if (utf8LengthWithin(key, JSON_VALUE_MAX_KEY_BYTES) === undefined) return false; + const childPath = appendSnapshotPath(frame.path, key); + if (utf8LengthWithin(key, JSON_VALUE_MAX_KEY_BYTES) === undefined) { + return invalidSnapshotPath(childPath); + } const keyBytes = serializedByteLength(key); - if (keyBytes === undefined || !addSerializedBytes(keyBytes + 1)) return false; + if (keyBytes === undefined || !addSerializedBytes(keyBytes + 1)) { + return invalidSnapshotPath(childPath); + } const descriptor = Reflect.getOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) return false; + if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) { + return invalidSnapshotPath(childPath); + } values.push(descriptor.value); } @@ -262,9 +315,10 @@ function snapshotObject( depth: frame.depth + 1, parent: canonical, key: ownKeys[index] as string, + path: appendSnapshotPath(frame.path, ownKeys[index] as string), }); } - return true; + return null; } function defineOwnDataProperty( diff --git a/src/schemas/lazy.test.ts b/src/schemas/lazy.test.ts index 66be7ab7cb..49c350a775 100644 --- a/src/schemas/lazy.test.ts +++ b/src/schemas/lazy.test.ts @@ -6,18 +6,6 @@ import { defineSchema } from "./define.ts"; import { lazySchema } from "./lazy.ts"; describe("lazySchema", () => { - it("does not materialize for inherited facade properties", () => { - let materializations = 0; - const getConcreteSchema = defineSchema((v) => v.string()); - const schema = lazySchema(() => { - materializations += 1; - return getConcreteSchema(); - }); - - assertEquals("toString" in schema, true); - assertEquals(materializations, 0); - }); - it("reflects non-configurable adapter metadata without violating proxy invariants", () => { let materializations = 0; const getConcreteSchema = defineSchema((v) => v.string()); diff --git a/src/schemas/lazy.ts b/src/schemas/lazy.ts index 0a97acf0c1..63c679a8c9 100644 --- a/src/schemas/lazy.ts +++ b/src/schemas/lazy.ts @@ -105,7 +105,7 @@ export function lazySchema(getSchema: () => Schema): Schema { return Reflect.get(target, prop, receiver); }, has(target, prop) { - return prop in target || prop in (schema() as object); + return Object.hasOwn(target, prop) || prop in (schema() as object) || prop in target; }, ownKeys(target) { if (!Reflect.isExtensible(target)) return Reflect.ownKeys(target); diff --git a/src/schemas/primitives.test.ts b/src/schemas/primitives.test.ts index ccf01ba996..4dfb26b4f7 100644 --- a/src/schemas/primitives.test.ts +++ b/src/schemas/primitives.test.ts @@ -13,7 +13,8 @@ import { getSemverSchema, getTimestampSchema, } from "./index.ts"; -import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/index.ts"; +import { snapshotBoundedJsonValue } from "./json-value.ts"; +import { MAX_PATH_LENGTH_CHARS } from "../utils/constants/index.ts"; function assertParseSuccess(result: { success: boolean }): void { assertEquals(result.success, true); @@ -101,6 +102,20 @@ describe("primitive schemas", () => { assertParseFailure(getJsonValueSchema().safeParse(cyclic)); }); + it("reports the narrowest safely observed rejection path", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + + assertEquals(snapshotBoundedJsonValue({ nested: { invalid: undefined } }), { + success: false, + path: ["nested", "invalid"], + }); + assertEquals(snapshotBoundedJsonValue({ nested: cyclic }), { + success: false, + path: ["nested", "self"], + }); + }); + it("rejects values deeper than the validation limit without throwing", () => { let value: unknown = null; for (let depth = 0; depth < 256; depth++) value = [value]; diff --git a/src/server/handlers/request/agent-stream.handler.test-helpers.ts b/src/server/handlers/request/agent-stream.handler.test-helpers.ts index cff6803d93..3ff56bcd4c 100644 --- a/src/server/handlers/request/agent-stream.handler.test-helpers.ts +++ b/src/server/handlers/request/agent-stream.handler.test-helpers.ts @@ -109,7 +109,11 @@ export function createNoopFsAdapter( }>, ): SourceContextTestFsAdapter { const adapter: SourceContextTestFsAdapter = { - readFile: async () => "", + // This adapter holds no files, so reads report absence the way a real + // filesystem does rather than returning empty content that hosted config + // evaluation would treat as a present, unparseable source. + readFile: (path: string) => + Promise.reject(Object.assign(new Error(`File not found: ${path}`), { code: "ENOENT" })), writeFile: async () => {}, exists: async () => false, async *readDir() {}, diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index ab9a1bce99..758179af19 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -1910,10 +1910,12 @@ describe("server/handlers/request/agent-stream.handler", () => { }); assertEquals(capturedAllowedRemoteTools, ["list_projects", "search_knowledge"]); assertEquals(capturedRemoteToolNames, ["search_knowledge", "list_projects"]); + // The environment is resolved before the source config is evaluated, so + // both the config and the MCP tool headers see the same variables. assertEquals(fetchUrls, [ - "https://api.veryfront.org/mcp", "https://api.veryfront.org/projects/support-agent-fork/environments", "https://api.veryfront.org/projects/support-agent-fork/environment-variables?environment_id=env-production&limit=100", + "https://api.veryfront.org/mcp", ]); }); @@ -2071,9 +2073,9 @@ describe("server/handlers/request/agent-stream.handler", () => { }); assertStringIncludes(capturedSystem ?? "", `api=${apiBaseUrl}`); assertEquals(fetchUrls, [ - `${new URL(apiBaseUrl).origin}/mcp`, `${apiBaseUrl}/projects/base-url-agent-fork/environments`, `${apiBaseUrl}/projects/base-url-agent-fork/environment-variables?environment_id=env-production-base-url&limit=100`, + `${new URL(apiBaseUrl).origin}/mcp`, ]); }); diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index bb9609100d..86a6e3d3c6 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -69,7 +69,8 @@ import { filterRuntimeProjectEnv, runWithProjectEnv, } from "../../project-env/index.ts"; -import { getConfig, type VeryfrontConfig } from "#veryfront/config/loader.ts"; +import { getHostedConfig, type VeryfrontConfig } from "#veryfront/config/loader.ts"; +import { prepareDeclarativeConfigContext } from "#veryfront/config/declarative-evaluator.ts"; import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; @@ -352,18 +353,71 @@ function createStaticRemoteToolSource( }; } +/** + * Environment label bound to one agent source. + * + * A bare release carries no authoritative environment identity, so it is + * evaluated under the `release` label against an empty environment and never + * inherits production secrets by convention. + */ +function buildAgentSourceEnvironmentName(sourceContext: RuntimeAgentSourceContext): string { + switch (sourceContext.type) { + case "branch": + return "preview"; + case "environment": + return sourceContext.environmentName; + case "release": + return "release"; + } +} + +/** + * Load the project environment this agent source may read. + * + * Control-plane requests don't go through the proxy and therefore don't carry + * x-environment-id, so the production environment ID is discovered from the API + * (one fetch per project per server lifetime, then cached). + */ +async function resolveAgentSourceEnvironment( + ctx: HandlerContext, + sourceContext: RuntimeAgentSourceContext, + apiAuthToken: string, +): Promise> { + if (sourceContext.type === "release") return {}; + if (!ctx.projectSlug || !apiAuthToken) return {}; + + const environmentId = ctx.environmentId ?? + await _resolveProductionEnvironmentId(ctx.projectSlug, apiAuthToken); + if (!environmentId) return {}; + + return await _agentEnvVarCache.get(environmentId, apiAuthToken, ctx.projectSlug); +} + +/** + * Load config for an exact agent source. + * + * This runs only on a shared multi-project runtime (see + * {@link AgentStreamHandler.withAgentSourceContext}), so the source is + * untrusted and is evaluated declaratively, bound to the same source and + * environment the run itself will use. + */ async function resolveAgentSourceConfig( ctx: HandlerContext, sourceContext: RuntimeAgentSourceContext, + environment: Record, ): Promise { const cacheKey = ctx.projectId ?? ctx.projectSlug; if (!cacheKey) { throw new Error("Explicit agent source requires a project identity"); } await ctx.adapter.fs.ensureSourceSnapshotFresh?.("agent-source-config"); - return await getConfig(ctx.projectDir, ctx.adapter, { + return await getHostedConfig(ctx.projectDir, ctx.adapter, { cacheKey, sourceContext: buildAgentSourceRunOptions(sourceContext), + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: buildAgentSourceEnvironmentName(sourceContext), + environment, + }), }); } @@ -690,9 +744,17 @@ export class AgentStreamHandler extends BaseHandler { requestScopedContext, payload.agentSource, async () => { + // Resolved before the config load because hosted evaluation binds + // config to the same environment the run will execute with. + const envVarsForAgent = await resolveAgentSourceEnvironment( + requestScopedContext, + payload.agentSource, + apiAuthToken, + ); const sourceConfig = await resolveAgentSourceConfig( requestScopedContext, payload.agentSource, + envVarsForAgent, ); const sourceScopedContext: HandlerContext = { ...requestScopedContext, @@ -742,31 +804,14 @@ export class AgentStreamHandler extends BaseHandler { conversationId: runtimeInput.threadId, }); - // Load project env vars so source-defined MCP tool headers resolve - // via _getProjectEnv(). Control-plane requests don't go through the proxy and - // therefore don't carry x-environment-id, so we discover the production env ID - // from the API (one fetch per project per server lifetime, then cached). - let envVarsForAgent: Record = {}; - if (sourceScopedContext.projectSlug && apiAuthToken) { - const environmentId = sourceScopedContext.environmentId ?? - await _resolveProductionEnvironmentId( - sourceScopedContext.projectSlug, - apiAuthToken, - ); - if (environmentId) { - envVarsForAgent = await _agentEnvVarCache.get( - environmentId, - apiAuthToken, - sourceScopedContext.projectSlug, - ); - logger.debug("Agent stream env vars loaded", { - runId: payload.runId, - projectSlug: sourceScopedContext.projectSlug, - environmentId, - count: Object.keys(envVarsForAgent).length, - }); - } - } + // Source-defined MCP tool headers resolve these via + // _getProjectEnv(); they are the same variables the source + // config was evaluated against. + logger.debug("Agent stream env vars loaded", { + runId: payload.runId, + projectSlug: sourceScopedContext.projectSlug, + count: Object.keys(envVarsForAgent).length, + }); const runAgentStream = () => createRuntimeAgentStreamResponse(runtimeInput, runtimeAgent, { diff --git a/src/server/runtime-handler/adapter-factory.test.ts b/src/server/runtime-handler/adapter-factory.test.ts index 666490f074..48856e2a69 100644 --- a/src/server/runtime-handler/adapter-factory.test.ts +++ b/src/server/runtime-handler/adapter-factory.test.ts @@ -2,6 +2,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { prepareDeclarativeConfigContext } from "#veryfront/config/declarative-evaluator.ts"; +import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; import { base64urlEncode, base64urlEncodeBytes } from "#veryfront/utils/base64url.ts"; import { resolveAdapter } from "./adapter-factory.ts"; import { defaultDiscoveryCache, ProjectDiscoveryCache } from "./local-project-discovery.ts"; @@ -11,6 +13,30 @@ const localAdapterCache = defaultDiscoveryCache.adapters; const encoder = new TextEncoder(); +async function preparePreviewHostedConfigContext() { + return { + sourceContext: { productionMode: false, branch: "main" } as const, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: {}, + }), + }; +} + +async function prepareProductionHostedConfigContext() { + return { + sourceContext: { + productionMode: true, + releaseId: "rel-1", + environmentName: "staging", + } as const, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: "staging", + environment: {}, + }), + }; +} + function encodePem(label: string, der: ArrayBuffer): string { const base64 = btoa(String.fromCharCode(...new Uint8Array(der))); const lines = base64.match(/.{1,64}/g) ?? [base64]; @@ -98,7 +124,10 @@ function createMockAdapter( writableFs: true, }, fs: { - readFile: async () => "", + readFile: (path: string) => + path in files + ? Promise.resolve("") + : Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)), writeFile: async () => {}, exists: async (path: string) => path in files, readDir: async function* () {}, @@ -206,6 +235,7 @@ describe("adapter-factory", () => { allowIframeEmbed: false, }, isProxyMode: true, + prepareHostedConfigContext: preparePreviewHostedConfigContext, }); assertEquals(result.isLocalProject, true); @@ -242,6 +272,7 @@ describe("adapter-factory", () => { allowIframeEmbed: false, }, isProxyMode: true, + prepareHostedConfigContext: preparePreviewHostedConfigContext, }); // The attacker-supplied path must not be adopted as the project root. @@ -322,6 +353,7 @@ describe("adapter-factory", () => { allowIframeEmbed: false, }, isProxyMode: true, + prepareHostedConfigContext: preparePreviewHostedConfigContext, }); assertEquals(result.isLocalProject, true); @@ -519,6 +551,7 @@ describe("adapter-factory", () => { }, isProxyMode: true, cache, + prepareHostedConfigContext: preparePreviewHostedConfigContext, }); assertEquals(result.isLocalProject, true); @@ -628,10 +661,18 @@ describe("adapter-factory", () => { token: string, fn: () => Promise, projectId?: string, - opts?: unknown, + opts?: { + productionMode?: boolean; + releaseId?: string | null; + branch?: string | null; + environmentName?: string | null; + }, ) => { calls.runWithContext = [slug, token, projectId, opts]; - return fn(); + return runWithRequestContext( + { projectSlug: slug, token, projectId, ...opts }, + fn, + ); }, }; return { @@ -640,41 +681,45 @@ describe("adapter-factory", () => { }; } - it("enters proxy mode config path when isProxyMode + slug + token", async () => { + it("binds authenticated production source context to hosted config", async () => { const { adapter, calls } = createExtendedMockAdapter(); - // Proxy mode with slug + token enters the config loading path. - // getConfig will either succeed (returning config) or throw (re-thrown in proxy mode). - let threw = false; - try { - await resolveAdapter({ - projectDir: "/base/project", - adapter, - config: undefined, - projectSlug: "proxy-slug", - projectId: "proj_proxy", - proxyToken: "tok-123", + const result = await resolveAdapter({ + projectDir: "/base/project", + adapter, + config: undefined, + projectSlug: "proxy-slug", + projectId: "proj_proxy", + proxyToken: "tok-123", + releaseId: "rel-1", + proxyEnv: "production", + branch: "main", + environmentName: "staging", + parsedDomain: { + slug: null, + branch: null, + environment: null, + isVeryfrontDomain: false, + isDraft: false, + allowIframeEmbed: false, + }, + req: await makeReq(), + isProxyMode: true, + prepareHostedConfigContext: prepareProductionHostedConfigContext, + }); + + assertEquals(result.config?.title, "Veryfront App"); + assertEquals(calls.runWithContext, [ + "proxy-slug", + "tok-123", + "proj_proxy", + { + productionMode: true, releaseId: "rel-1", - proxyEnv: "production", - branch: "main", + branch: undefined, environmentName: "staging", - parsedDomain: { - slug: null, - branch: null, - environment: null, - isVeryfrontDomain: false, - isDraft: false, - allowIframeEmbed: false, - }, - req: await makeReq(), - isProxyMode: true, - }); - } catch { - threw = true; - } - - // Verify the proxy config path was entered: runWithContext should have been called - assertEquals(calls.runWithContext !== undefined || threw, true); + }, + ]); }); it("refreshes mutable source before loading proxy config", async () => { @@ -691,17 +736,30 @@ describe("adapter-factory", () => { _slug: string, _token: string, fn: () => Promise, - ) => fn(), + projectId?: string, + opts?: { + productionMode?: boolean; + releaseId?: string | null; + branch?: string | null; + environmentName?: string | null; + }, + ) => runWithRequestContext({ projectSlug: _slug, token: _token, projectId, ...opts }, fn), ensureSourceSnapshotFresh: () => { sourceFresh = true; return Promise.resolve(); }, readFile: (path: string) => { if (path !== "/veryfront.config.ts") { - return Promise.reject(new Error(`Not found: ${path}`)); + return Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)); } return Promise.resolve( - `export default { router: "${sourceFresh ? "pages" : "app"}" };`, + ` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + router: "${sourceFresh ? "pages" : "app"}", + title: environmentName + ":" + getEnv("TENANT"), + })); + `, ); }, }; @@ -728,10 +786,18 @@ describe("adapter-factory", () => { }, req: await makeReq(), isProxyMode: true, + prepareHostedConfigContext: async () => ({ + sourceContext: { productionMode: false, branch: "main" }, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: { TENANT: "tenant-value" }, + }), + }), }); assertEquals(sourceFresh, true); assertEquals(result.config?.router, "pages"); + assertEquals(result.config?.title, "preview:tenant-value"); }); it("re-throws config loading errors in proxy mode", async () => { @@ -772,6 +838,7 @@ describe("adapter-factory", () => { }, req, isProxyMode: true, + prepareHostedConfigContext: preparePreviewHostedConfigContext, }), Error, "proxy config fail", @@ -871,6 +938,18 @@ describe("adapter-factory", () => { req, pathname: "/api/control-plane/runs/run_1/execute", isProxyMode: true, + prepareHostedConfigContext: async () => ({ + ...(await prepareProductionHostedConfigContext()), + sourceContext: { + productionMode: true, + releaseId: "rel-stale", + environmentName: "production", + }, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: "production", + environment: {}, + }), + }), }), Error, "execute config fail", diff --git a/src/server/runtime-handler/adapter-factory.ts b/src/server/runtime-handler/adapter-factory.ts index 8160250ed9..b09b87f5a1 100644 --- a/src/server/runtime-handler/adapter-factory.ts +++ b/src/server/runtime-handler/adapter-factory.ts @@ -8,11 +8,15 @@ */ import { getBaseLogger } from "#veryfront/utils"; -import { getErrorMessage } from "#veryfront/errors"; +import { CACHE_INVARIANT_VIOLATION, getErrorMessage } from "#veryfront/errors"; import { runtime } from "#veryfront/platform/adapters/detect.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { isExtendedFSAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; -import { getConfig } from "#veryfront/config/loader.ts"; +import { + getConfig, + getHostedConfig, + type PreparedHostedConfigContext, +} from "#veryfront/config/loader.ts"; import type { VeryfrontConfig } from "#veryfront/config"; import { isConfigOptionalControlPlaneRunRequest } from "#veryfront/channels/control-plane.ts"; import { timeAsync } from "./request-lifecycle.ts"; @@ -76,6 +80,13 @@ interface AdapterResolutionOptions { proxyTrusted?: boolean; /** Optional injectable cache (defaults to module-level singleton) */ cache?: ProjectDiscoveryCache; + /** + * Authenticated source and environment snapshot for hosted config. Proxy + * config must never derive either value independently inside this factory. + */ + prepareHostedConfigContext?: ( + isLocalProject: boolean, + ) => Promise; } function usesExactSourceConfig(opts: AdapterResolutionOptions): boolean { @@ -85,6 +96,29 @@ function usesExactSourceConfig(opts: AdapterResolutionOptions): boolean { isConfigOptionalControlPlaneRunRequest(opts.req.method, opts.pathname); } +function shouldDeferConfigLoad(opts: AdapterResolutionOptions): boolean { + if (usesExactSourceConfig(opts)) return true; + // There is no immutable production source to evaluate until resolution has + // selected a release. Let environment resolution return its canonical 404. + return opts.isProxyMode && !!opts.projectSlug && opts.proxyEnv === "production" && + !opts.releaseId; +} + +async function prepareProxyConfigLoad( + opts: AdapterResolutionOptions, + isLocalProject: boolean, +): Promise { + if (!opts.projectSlug || !opts.prepareHostedConfigContext) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: "Proxy project config requires an authenticated declarative evaluation context", + }); + } + return { + cacheKey: opts.projectId ?? opts.projectSlug, + ...await opts.prepareHostedConfigContext(isLocalProject), + }; +} + /** * Resolve the effective adapter and config for a request. * @@ -146,8 +180,18 @@ export async function resolveAdapter( effectiveAdapter = cache.adapters.get(effectiveProjectDir)!; - if (usesExactSourceConfig(opts)) { + if (shouldDeferConfigLoad(opts)) { effectiveConfig = undefined; + } else if (opts.isProxyMode) { + const hosted = await prepareProxyConfigLoad(opts, true); + effectiveConfig = await timeAsync( + "config:load-project", + () => + getHostedConfig(effectiveProjectDir, effectiveAdapter, { + ...hosted, + signal: opts.req.signal, + }), + ); } else { effectiveConfig = await timeAsync( "config:load-project", @@ -162,7 +206,7 @@ export async function resolveAdapter( }); } } else if (opts.isProxyMode && opts.projectSlug && opts.proxyToken) { - if (usesExactSourceConfig(opts)) { + if (shouldDeferConfigLoad(opts)) { logger.debug("Skipping outer config load for exact-source control-plane request", { projectSlug: opts.projectSlug, projectId: opts.projectId, @@ -181,13 +225,15 @@ export async function resolveAdapter( // Unlike local projects, proxy mode config loading failures are propagated // because proceeding without config causes silent 404s for valid projects. try { - effectiveConfig = await timeAsync("config:load-proxy-project", () => { + effectiveConfig = await timeAsync("config:load-proxy-project", async () => { + const hosted = await prepareProxyConfigLoad(opts, false); const loadCurrentConfig = async (): Promise => { // Config controls route and primitive discovery, so it must be read // from the same current snapshot that those consumers will retain. await effectiveAdapter.fs.ensureSourceSnapshotFresh?.("config-load"); - return await getConfig(effectiveProjectDir, effectiveAdapter, { - cacheKey: opts.projectId ?? opts.projectSlug, + return await getHostedConfig(effectiveProjectDir, effectiveAdapter, { + ...hosted, + signal: opts.req.signal, }); }; @@ -198,10 +244,10 @@ export async function resolveAdapter( loadCurrentConfig, opts.projectId, { - productionMode: opts.proxyEnv === "production", - releaseId: opts.releaseId, - branch: opts.branch ?? opts.parsedDomain.branch ?? null, - environmentName: opts.environmentName, + productionMode: hosted.sourceContext.productionMode, + releaseId: hosted.sourceContext.releaseId, + branch: hosted.sourceContext.branch, + environmentName: hosted.sourceContext.environmentName, }, ); } diff --git a/src/server/runtime-handler/handler-context-builder.ts b/src/server/runtime-handler/handler-context-builder.ts index 3ef0abadaa..e05db73c24 100644 --- a/src/server/runtime-handler/handler-context-builder.ts +++ b/src/server/runtime-handler/handler-context-builder.ts @@ -58,6 +58,11 @@ export interface HandlerContextOptions { environmentId: string | undefined; /** Skip render-specific enriched context requirements for non-render control-plane routes */ skipEnrichedContext?: boolean; + /** + * Prepares the authenticated hosted evaluation context for this request. + * Supplied only for shared multi-project runtimes. + */ + prepareHostedConfigContext?: HandlerContext["prepareHostedConfigContext"]; } /** @@ -112,6 +117,7 @@ export function buildHandlerContext(opts: HandlerContextOptions): HandlerContext routeRegistry: opts.routeRegistry, isLocalProject: opts.isLocalProject, environmentId: opts.environmentId, + prepareHostedConfigContext: opts.prepareHostedConfigContext, enriched: enrichedContext, }; } diff --git a/src/server/runtime-handler/project-runtime-context.test.ts b/src/server/runtime-handler/project-runtime-context.test.ts index 252912cd49..d3026886f8 100644 --- a/src/server/runtime-handler/project-runtime-context.test.ts +++ b/src/server/runtime-handler/project-runtime-context.test.ts @@ -53,7 +53,10 @@ function createMockAdapter( writableFs: true, }, fs: { - readFile: async () => "", + readFile: (path: string) => + path in files + ? Promise.resolve("") + : Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)), writeFile: async () => {}, exists: async (path: string) => path in files, readDir: async function* () {}, @@ -650,19 +653,31 @@ describe("resolveProjectRuntimeContext", () => { const adapter = createMockAdapter({ "/attacker/chosen/path": { isDirectory: true }, "/attacker/chosen/path/app": { isDirectory: true }, - }); + "/base/project/veryfront.config.ts": { isDirectory: false, isFile: true }, + }); + adapter.fs.readFile = (path: string) => + path === "/base/project/veryfront.config.ts" + ? Promise.resolve(` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: environmentName + ":" + getEnv("TENANT"), + })); + `) + : Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)); defaultDiscoveryCache.adapters.set("/attacker/chosen/path", adapter); const req = new Request("http://localhost/page", { headers: { "x-project-slug": "remote-project", "x-project-id": "proj-remote", "x-token": "proxy-token", + "x-environment-id": "env-remote", "x-project-path": "/attacker/chosen/path", }, }); const url = new URL(req.url); const headers = extractRequestHeaders(req, url, false); + let envLoadCount = 0; const result = await resolveProjectRuntimeContext(makeRuntimeContextInput({ req, url, @@ -679,11 +694,20 @@ describe("resolveProjectRuntimeContext", () => { proxyEnv: "preview", parsedDomain: defaultParsedDomain, }, + envVarCache: { + get: () => { + envLoadCount += 1; + return Promise.resolve({ TENANT: "tenant-value" }); + }, + }, })); + assertEquals(envLoadCount, 1); assertEquals(result.adapter.isLocalProject, false); assertEquals(result.adapter.projectDir, "/base/project"); assertEquals(defaultDiscoveryCache.projects.has("remote-project"), false); + assertEquals(result.handlerContext?.config?.title, "preview:tenant-value"); + assertEquals(result.rawEnvVars, { TENANT: "tenant-value" }); }); it("returns production 404 responses and standalone synthetic fallback from environment resolution", async () => { diff --git a/src/server/runtime-handler/project-runtime-context.ts b/src/server/runtime-handler/project-runtime-context.ts index 85d80731d4..bdfbcfee7f 100644 --- a/src/server/runtime-handler/project-runtime-context.ts +++ b/src/server/runtime-handler/project-runtime-context.ts @@ -1,5 +1,7 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import type { VeryfrontConfig } from "#veryfront/config"; +import { prepareDeclarativeConfigContext } from "#veryfront/config/declarative-evaluator.ts"; +import type { VirtualConfigSourceContext } from "#veryfront/cache/keys.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { RouteRegistry } from "#veryfront/routing/registry/index.ts"; import type { SecurityConfig } from "#veryfront/types"; @@ -198,6 +200,49 @@ export async function resolveProjectRuntimeContext( const profileAdapter = input.profileAdapter ?? ((operation) => operation()); const profileEnvVars = input.profileEnvVars ?? ((operation) => operation()); + type HostedConfigLoad = { + readonly sourceContext: VirtualConfigSourceContext; + readonly preparedContext: Awaited>; + readonly environment: Record; + }; + let hostedConfigLoadPromise: Promise | undefined; + const prepareHostedConfigContext = (isLocalProject: boolean): Promise => { + hostedConfigLoadPromise ??= (async () => { + const productionMode = projectRes.proxyEnv === "production"; + const environmentName = productionMode ? projectRes.environmentName ?? "release" : "preview"; + const sourceContext: VirtualConfigSourceContext = productionMode + ? { + productionMode: true, + releaseId: projectRes.releaseId ?? null, + environmentName: projectRes.environmentName, + } + : { + productionMode: false, + branch: reqCtx.branch ?? projectRes.parsedDomain.branch ?? "main", + }; + + const environmentId = input.environmentId ?? input.headers.environmentId; + const mayLoadEnvironment = !isLocalProject && + (!productionMode || projectRes.environmentName !== undefined); + const environment = mayLoadEnvironment && environmentId && reqCtx.token && + projectRes.projectSlug + ? await profileEnvVars(() => + input.envVarCache.get(environmentId, reqCtx.token!, projectRes.projectSlug!) + ) + : {}; + + return { + sourceContext, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName, + environment, + }), + environment, + }; + })(); + return hostedConfigLoadPromise; + }; + const adapterRes = await profileAdapter(() => resolveAdapter({ req: input.req, @@ -215,6 +260,7 @@ export async function resolveProjectRuntimeContext( pathname: input.url.pathname, isProxyMode: input.isProxyMode, proxyTrusted: input.proxyTrust.proxyTrusted, + ...(input.isProxyMode ? { prepareHostedConfigContext } : {}), }) ); @@ -268,11 +314,21 @@ export async function resolveProjectRuntimeContext( moduleServerUrl: input.moduleServerUrl, environmentId: input.environmentId ?? input.headers.environmentId, skipEnrichedContext: input.skipEnrichedContext ?? shouldSkipEnrichedContext(input.url.pathname), + // Handlers that load config themselves reuse this request's identity + // instead of deriving their own. + ...(input.isProxyMode + ? { + prepareHostedConfigContext: () => prepareHostedConfigContext(adapterRes.isLocalProject), + } + : {}), }); - let rawEnvVars: Record = {}; + let rawEnvVars: Record = hostedConfigLoadPromise + ? (await hostedConfigLoadPromise).environment + : {}; const environmentId = input.environmentId ?? input.headers.environmentId; if ( + !hostedConfigLoadPromise && !adapterRes.isLocalProject && environmentId && reqCtx.token && diff --git a/src/server/services/rsc/endpoints/rsc-bundles.generated.ts b/src/server/services/rsc/endpoints/rsc-bundles.generated.ts index e730c5222d..a73e301be7 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",I=gt;function Ee(e=I){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=I){return Ee(e).react}function he(e=I){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 Vo=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 Go=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var zo=16*1024*1024;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/`},_e={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 Rt={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},Wo=Rt.CACHE;var Xo={HMR_RUNTIME:_e.HMR_RUNTIME,ERROR_OVERLAY:_e.ERROR_OVERLAY};var O=ne.RSC,Te=ne.FS,oe="veryfront-hydration-data",N="rsc-root",k="x-veryfront-dependency-pins";var x=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 ht(){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 $=ht(),l=new x("RSC",$),ei=new x("PREFETCH",$),ti=new x("HYDRATE",$),ri=new x("VERYFRONT",$);function S(e=document){try{let t=e.getElementById(oe);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function F(e,t){if(!t?.startsWith("on:"))return!1;try{let r=e.getElementById(oe);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 V(e){return e?.clientModuleStrategy?e.clientModuleStrategy:e?.dev?"fs":"rsc-module"}function _t(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 Tt(e,t){return _t(`${Te}${te(e)}.js`,t)}function xt(e,t,r){let n=t?`&v=${encodeURIComponent(t)}`:"";return B(`${O}module?rel=${encodeURIComponent(e)}${n}`,r)}function D(e){let t=e?.dependencyPinningCacheKey;return t?.startsWith("on:")?{[k]:t}:{}}function St(e){return e.replace(/^\\/+_vf_modules\\//,"").replace(/^\\/+/,"").replace(/\\.js$/,"")}var Ct=/\\.(tsx|ts|jsx|mdx|js)$/;function At(e){let t=St(e),r=[e,t];return Ct.test(t)||r.push(`${t}.tsx`,`${t}.ts`,`${t}.jsx`,`${t}.mdx`,`${t}.js`),Array.from(new Set(r))}function bt(e,t){if(!e)return null;for(let r of At(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(Tt(r,e.version),e.dependencyPinningCacheKey):null}let t=bt(e.releaseAssetModules,e.rel);return t||xt(e.rel,e.version,e.dependencyPinningCacheKey)}function j(e=document,t=I){let r=re(e);return{react:v("react",r)?"react":Re(t),reactDomClient:v("react-dom/client",r)?"react-dom/client":he(t)}}function xe(e=document){let t=re(e);return v("veryfront/router",t)?"veryfront/router":null}var Y={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"},di={debug:Y.gray,info:Y.green,warn:Y.yellow,error:Y.red};var y="[REDACTED]",E=Reflect.apply;var Se=RegExp.prototype.exec,T=RegExp.prototype[Symbol.replace],fi=String.prototype.charCodeAt,Ce=String.prototype.slice,It=String.prototype.toLowerCase,Ot=/[^a-z0-9]/g;function ie(e){let t=E(It,e,[]);return E(T,Ot,[t,""])}function z(e,t,r){return r===void 0?E(Ce,e,[t]):E(Ce,e,[t,r])}var Nt=["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"],Dt=512,wt=128,w=new Map;function Ie(e){let t=e.length<=wt;if(t){let o=w.get(e);if(o!==void 0)return o}let r=ie(e),n=Nt.some(o=>r.includes(o));if(t){if(w.size>=Dt){let o=w.keys().next().value;o!==void 0&&w.delete(o)}w.set(e,n)}return n}var Mt=["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"],Lt=new Set(Mt.map(ie)),Pt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Ht=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Ut=3;function vt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function kt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function Oe(e){return kt(e)||e==="_"||e==="$"}function $t(e){if(!e)return!1;let t=e.charCodeAt(0);return Oe(e)||t>=48&&t<=57||e==="."||e==="-"}function Ne(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!Oe(e[r]))return!1;for(r++;$t(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 De(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||vt(e)}function we(e,t){let r=t;for(;r=e.length||Ne(e,r)}function Ft(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(Ae(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&&Ae(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!De(f)){g++;continue}let R=g;if(g=we(e,g),g>=e.length||Ne(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function be(e,t,r,n){let o=0,s="";for(let a=E(Se,t,[e]);a;a=E(Se,t,[e])){let u=a[r];if(!Ie(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=Ft(e,d);s+=z(e,o,a.index),s+=a[0],s+=f.replacement,o=f.end,t.lastIndex=f.end}return o===0?e:s+z(e,o)}function Vt(e,t,r){let n=r.search(/[ \\t]/);if(n<0)return!1;let o=`${t}:${z(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 Bt(e){let t=e;for(let r=0;r{let s=o.indexOf(":");if(s===-1)return`${n}${y}@`;let a=z(o,0,s);return`${n}${a}:${y}@`}]);return t=E(T,Ht,[t,(r,n,o,s)=>Vt(n,o,s)?r:`${n}${o}:${y}@`]),t=E(T,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,o,s)=>{let a=Bt(o);return Lt.has(ie(a))||Ie(a)?`${n}${o}=${y}`:r}]),t=E(T,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=E(T,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(T,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=be(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=be(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var Gt=2048;var Ri=64*1024,jt=256,Yt="https://veryfront.com/docs/errors/",Me="...[truncated]",ae="unknown-error";function Le(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Me.length);return`${zt(e,r)}${Me}`}function zt(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 Kt(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:Le(se(e),Gt)}function Wt(e){let t=typeof e=="string"?se(e):ae,r=Le(t||ae,jt),n=Kt(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(Wt(e));return`${Yt}${t}`}var Xt=Object.freeze,qt=Object.getOwnPropertyDescriptors,Pe=Number.isFinite,Ue=new WeakSet,Jt=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 Xt(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");Ue.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=He(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=He(this);return K(r?.slug??"unknown-error")}};function ve(e){return typeof e=="object"&&e!==null&&Ue.has(e)}function He(e){return ve(e)?Zt(e):null}function Zt(e){try{if(!ve(e))return null;let t=qt(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"||!Jt.has(o)||typeof s!="number"||!Pe(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!Pe(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 Qt=i({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),er=i({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),tr=i({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),rr=i({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),nr=i({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),or=i({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),ir=i({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),sr=i({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),ar=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"}),ke=i({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),cr=i({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),$e={"unknown-error":Qt,"authentication-required":er,"permission-denied":tr,"file-not-found":rr,"resource-not-found":nr,"invalid-argument":or,"timeout-error":ir,"initialization-error":sr,"not-supported":ar,"security-violation":ue,"input-validation-failed":ke,"project-source-empty":cr};var le="data-vf-react-head-owner";var Ai=2*1024*1024;var bi=64*1024,Ii=1024*1024,Oi=1024*1024;var Ni=new TextEncoder;var Pi=64*1024,Hi=1024*1024,Ui=new TextEncoder;var ur=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function lr(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 Fe(...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"||!ur.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 lr("error registry",...e)}var dr=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"}),gr=i({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),fr=i({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),pr=i({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),yr=i({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),mr=i({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),Er=i({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),Rr=i({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),hr=i({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),_r=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"}),Tr=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":dr,"config-invalid":gr,"config-parse-error":fr,"config-validation-error":pr,"config-type-error":yr,"import-map-invalid":mr,"cors-config-invalid":Er,"config-validation-failed":Rr,"webhook-config-invalid":hr,"schedule-config-invalid":_r,"trigger-config-invalid":Tr};var xr=i({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Sr=i({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),Cr=i({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),Ar=i({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),br=i({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Ir=i({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Or=i({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Nr=i({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Be={"build-failed":xr,"bundle-error":Sr,"typescript-error":Cr,"mdx-compile-error":Ar,"asset-optimization-error":br,"ssg-generation-error":Ir,"sourcemap-error":Or,"compilation-error":Nr};var Dr=i({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),wr=i({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Mr=i({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Lr=i({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Pr=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"}),Hr=i({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),Ur=i({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),vr=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"}),kr=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"}),$r=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"}),Ge={"hydration-mismatch":Dr,"render-error":wr,"component-error":Mr,"layout-not-found":Lr,"page-not-found":Pr,"api-error":Hr,"middleware-error":Ur,"trigger-target-not-found":vr,"trigger-execution-failed":kr,"trigger-not-supported":$r};var Fr=i({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Vr=i({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Br=i({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Gr=i({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),jr=i({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Yr=i({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),je={"route-conflict":Fr,"invalid-route-file":Vr,"route-handler-invalid":Br,"dynamic-route-error":Gr,"route-params-error":jr,"api-route-error":Yr};var zr=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"}),Kr=i({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Wr=i({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),Xr=i({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),qr=i({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Jr=i({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),Ye={"module-not-found":zr,"import-resolution-error":Kr,"circular-dependency":Wr,"invalid-import":Xr,"dependency-missing":qr,"version-mismatch":Jr};var Zr=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"}),Qr=i({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),en=i({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),tn=i({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),rn=i({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),nn=i({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),on=i({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),sn=i({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),an=i({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),cn=i({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),un=i({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),ln=i({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),dn=i({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),gn=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"}),fn=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":Zr,"server-start-error":Qr,"cache-error":en,"file-watch-error":tn,"request-error":rn,"service-overloaded":nn,"semaphore-timeout":on,"circuit-breaker-open":sn,"cache-path-mismatch":an,"network-error":cn,"api-client-error":un,"token-storage-error":ln,"cache-invariant-violation":dn,"release-not-found":gn,"fallback-exhausted":fn};var pn=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"}),yn=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"}),mn=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"}),En=i({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),Rn=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"}),hn=i({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),Ke={"client-boundary-violation":pn,"server-only-in-client":yn,"client-only-in-server":mn,"invalid-use-client":En,"invalid-use-server":Rn,"rsc-payload-error":hn};var _n=i({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),Tn=i({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),xn=i({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),Sn=i({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),Cn=i({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),We={"hmr-error":_n,"dev-server-error":Tn,"fast-refresh-error":xn,"error-overlay-error":Sn,"source-map-error":Cn};var An=i({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),bn=i({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),In=i({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),On=i({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),Nn=i({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Dn=i({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),wn=i({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Mn=i({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),Ln=i({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),Pn=i({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Hn=i({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Un=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 "}),Xe={"deployment-error":An,"platform-error":bn,"env-var-missing":In,"production-build-required":On,"environment-not-found":Nn,"release-missing-version":Dn,"release-build-timeout":wn,"deployment-verification-timeout":Mn,"push-receipt-missing":Ln,"source-digest-mismatch":Pn,"preview-hostname-too-long":Hn,"branch-not-found":Un};var vn=i({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),kn=i({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),$n=i({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Fn=i({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Vn=i({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Bn=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"}),Gn=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"}),qe={"agent-error":vn,"agent-not-found":kn,"agent-timeout":$n,"agent-intent-error":Fn,"orchestration-error":Vn,"cost-limit-exceeded":Bn,"tool-id-conflict":Gn};var _s=Fe(Ve,Be,Ge,je,Ye,ze,Ke,We,Xe,qe,$e);var jn=[{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 Yn(){return jn.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function zn(){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 Yn())if(!(r&&a==="inline script")&&(s.lastIndex=0,!!s.test(e)&&(o&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),n||!zn())))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 Kn(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"){Kn(e,u);try{qn(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function Wn(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&&F(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,Wn(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 Xn(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 qn(e,t){let r=Xn(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 Jn=new Set(["server","client","html","fragment"]);function Qe(e){if(!e)return[];try{let t=JSON.parse(e);return Qn(t)?t.nodes:[]}catch{return[]}}async function ge(e,t,r){return await Promise.all(e.map(n=>Zn(n,t,r)))}async function Zn(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await ge(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 Qn(e){return!de(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>et(t,0))}function et(e,t){return t>100||!de(e)||!Jn.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&&!de(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>et(r,t+1))}function de(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function eo(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=xe(r);if(!n)return e;let s=(await import(n)).wrapForHydration;return typeof s!="function"?e:s(e,{params:eo(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var to="Unknown dependency snapshot",ro="export default null; // Unknown dependency snapshot",fe="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function no(){return globalThis}async function oo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===to||t===ro}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await oo(e))return!1;let r=no();if(r[fe])return!0;r[fe]=!0;try{t()}catch{return delete r[fe],!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 io=100;function so(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=io){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 ao(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 co(e){return Qe(e.dataset?.rscChildren)}function uo(e){return"/_veryfront/rsc/manifest"}function lo(e){return D(e)}async function go(e=document){try{let t=S(e),r=await fetch(uo(t),{headers:lo(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function rt(e,t,r,n={}){let o=fo(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{so(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 fo(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 po(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 go(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=po(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=V(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=ao(c),b=co(c),ot=await ge(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)}}async function yo(){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 mo=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)||mo.has(e.tagName.toUpperCase())}function Eo(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function Ro(e,t){return e===t}function ho(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 _o(e,t){for(let r of e){let n=[...r.hasAttribute(le)?[r]:[],...r.querySelectorAll(`[${le}]`)];for(let o of n)t.contains(o)||o.remove()}}function To(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function xo(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function So(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function Co(e){return e==="rsc-module"}function Ao(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function bo(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function Io(e,t){try{let r=await fetch(O+"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 Oo(e,t,r){try{let{React:n,ReactDOM:o}=await yo(),s=bo(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=Eo(d,document.body),g=Ro(c,document.body)?ho(d,document.body):c;_o(d,g);let f=await W(n.createElement(u,{}),r);return Co(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 No(e,t){try{let r=await fetch(O+"payload"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";let n=await r.json();if(F(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 Do(){try{let e=S(document),t=Ao(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(So()){await q();return}let r=e?.pagePath,n=V(e);if(r){if(To(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await Oo(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!xo(document,e))return;let o=await Io(t,e);if(o==="snapshot-conflict")return;if(o==="success"){await q();return}let s=await No(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=()=>{Do()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{Do as boot,bo as buildPageHydrationModuleUrl,Ao as buildRSCTransportQuery,_o as retireAbandonedHeadOwnerMarkers,Eo as selectHydrationRoot,xo as shouldAttemptRSCTransport,So as shouldHydrateOnly,Co as shouldRenderPageComponent,To as shouldUsePageRendererHydration,Ro as shouldWrapPageHydrationRoot};\n'; + 'var ct=Object.defineProperty;var ut=(e,t,r)=>t in e?ct(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var m=(e,t,r)=>ut(e,typeof t!="symbol"?t+"":t,r);var lt="3.2.3";function dt(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 dt(e,t,r,{external:n?["react"]:void 0,deps:{csstype:lt}})}var ft="19.2.4",O=ft;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 pt(e){return e.replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}function yt(e){if(typeof globalThis.btoa=="function")try{return globalThis.btoa(e)}catch{return mt(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 mt(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 pt(yt(e))}var Yo=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 Et(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?Et(t.textContent):{}}var Wo=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Rt=5e3,ht=1e4,Jo=16*1024*1024,_t=5e3;var Tt=100;var xt=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),Zo=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),Qo=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Rt,api:3e4,ssr:ht,hmr:3e4,sandbox:_t}),cache:Object.freeze({jit:Object.freeze({maxSize:Tt,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/`},Te={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 St={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},ti=St.CACHE;var ri={HMR_RUNTIME:Te.HMR_RUNTIME,ERROR_OVERLAY:Te.ERROR_OVERLAY};var I=ne.RSC,xe=ne.FS,oe="veryfront-hydration-data",N="rsc-root",k="x-veryfront-dependency-pins";var x=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 Ct(){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 $=Ct(),l=new x("RSC",$),ai=new x("PREFETCH",$),ci=new x("HYDRATE",$),ui=new x("VERYFRONT",$);function S(e=document){try{let t=e.getElementById(oe);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function F(e,t){if(!t?.startsWith("on:"))return!1;try{let r=e.getElementById(oe);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 V(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(`${xe}${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 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"},Ri={debug:z.gray,info:z.green,warn:z.yellow,error:z.red};var y="[REDACTED]",E=Reflect.apply;var Ce=RegExp.prototype.exec,T=RegExp.prototype[Symbol.replace],_i=String.prototype.charCodeAt,Ae=String.prototype.slice,Mt=String.prototype.toLowerCase,Lt=/[^a-z0-9]/g;function ie(e){let t=E(Mt,e,[]);return E(T,Lt,[t,""])}function Y(e,t,r){return r===void 0?E(Ae,e,[t]):E(Ae,e,[t,r])}var Pt=["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"],Ht=512,Ut=128,w=new Map;function Ie(e){let t=e.length<=Ut;if(t){let o=w.get(e);if(o!==void 0)return o}let r=ie(e),n=Pt.some(o=>r.includes(o));if(t){if(w.size>=Ht){let o=w.keys().next().value;o!==void 0&&w.delete(o)}w.set(e,n)}return n}var vt=["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"],kt=new Set(vt.map(ie)),$t=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Ft=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Vt=3;function Bt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function Gt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function Ne(e){return Gt(e)||e==="_"||e==="$"}function jt(e){if(!e)return!1;let t=e.charCodeAt(0);return Ne(e)||t>=48&&t<=57||e==="."||e==="-"}function De(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!Ne(e[r]))return!1;for(r++;jt(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 we(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||Bt(e)}function Me(e,t){let r=t;for(;r=e.length||De(e,r)}function zt(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(be(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&&be(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!we(f)){g++;continue}let R=g;if(g=Me(e,g),g>=e.length||De(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function Oe(e,t,r,n){let o=0,s="";for(let a=E(Ce,t,[e]);a;a=E(Ce,t,[e])){let u=a[r];if(!Ie(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=zt(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 Yt(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 Kt(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(T,Ft,[t,(r,n,o,s)=>Yt(n,o,s)?r:`${n}${o}:${y}@`]),t=E(T,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,o,s)=>{let a=Kt(o);return kt.has(ie(a))||Ie(a)?`${n}${o}=${y}`:r}]),t=E(T,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=E(T,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(T,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=Oe(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=Oe(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var Wt=2048;var Ai=64*1024,Xt=256,qt="https://veryfront.com/docs/errors/",Le="...[truncated]",ae="unknown-error";function Pe(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Le.length);return`${Jt(e,r)}${Le}`}function Jt(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 Zt(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:Pe(se(e),Wt)}function Qt(e){let t=typeof e=="string"?se(e):ae,r=Pe(t||ae,Xt),n=Zt(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(Qt(e));return`${qt}${t}`}var er=Object.freeze,tr=Object.getOwnPropertyDescriptors,He=Number.isFinite,ve=new WeakSet,rr=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 er(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");ve.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=Ue(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=Ue(this);return K(r?.slug??"unknown-error")}};function ke(e){return typeof e=="object"&&e!==null&&ve.has(e)}function Ue(e){return ke(e)?nr(e):null}function nr(e){try{if(!ke(e))return null;let t=tr(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"||!rr.has(o)||typeof s!="number"||!He(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!He(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 or=i({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),ir=i({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),sr=i({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),ar=i({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),cr=i({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),ur=i({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),lr=i({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),dr=i({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),gr=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"}),$e=i({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),fr=i({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),Fe={"unknown-error":or,"authentication-required":ir,"permission-denied":sr,"file-not-found":ar,"resource-not-found":cr,"invalid-argument":ur,"timeout-error":lr,"initialization-error":dr,"not-supported":gr,"security-violation":ue,"input-validation-failed":$e,"project-source-empty":fr};var le="data-vf-react-head-owner";var Mi=2*1024*1024;var Li=64*1024,Pi=1024*1024,Hi=1024*1024;var Ui=new TextEncoder;var Vi=64*1024,Bi=1024*1024,Gi=new TextEncoder;var pr=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function yr(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 Ve(...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"||!pr.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 yr("error registry",...e)}var mr=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"}),Er=i({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),Rr=i({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),hr=i({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),_r=i({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),Tr=i({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),xr=i({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),Sr=i({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),Cr=i({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),Ar=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"}),br=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"}),Be={"config-not-found":mr,"config-invalid":Er,"config-parse-error":Rr,"config-validation-error":hr,"config-type-error":_r,"import-map-invalid":Tr,"cors-config-invalid":xr,"config-validation-failed":Sr,"webhook-config-invalid":Cr,"schedule-config-invalid":Ar,"trigger-config-invalid":br};var Or=i({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Ir=i({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),Nr=i({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),Dr=i({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),wr=i({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Mr=i({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Lr=i({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Pr=i({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Ge={"build-failed":Or,"bundle-error":Ir,"typescript-error":Nr,"mdx-compile-error":Dr,"asset-optimization-error":wr,"ssg-generation-error":Mr,"sourcemap-error":Lr,"compilation-error":Pr};var Hr=i({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Ur=i({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),vr=i({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),kr=i({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),$r=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"}),Fr=i({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),Vr=i({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Br=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"}),Gr=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"}),jr=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"}),je={"hydration-mismatch":Hr,"render-error":Ur,"component-error":vr,"layout-not-found":kr,"page-not-found":$r,"api-error":Fr,"middleware-error":Vr,"trigger-target-not-found":Br,"trigger-execution-failed":Gr,"trigger-not-supported":jr};var zr=i({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Yr=i({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Kr=i({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Wr=i({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),Xr=i({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),qr=i({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),ze={"route-conflict":zr,"invalid-route-file":Yr,"route-handler-invalid":Kr,"dynamic-route-error":Wr,"route-params-error":Xr,"api-route-error":qr};var Jr=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"}),Zr=i({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Qr=i({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),en=i({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),tn=i({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),rn=i({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),Ye={"module-not-found":Jr,"import-resolution-error":Zr,"circular-dependency":Qr,"invalid-import":en,"dependency-missing":tn,"version-mismatch":rn};var nn=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"}),on=i({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),sn=i({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),an=i({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),cn=i({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),un=i({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),ln=i({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),dn=i({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),gn=i({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),fn=i({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),pn=i({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),yn=i({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),mn=i({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),En=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"}),Rn=i({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),Ke={"port-in-use":nn,"server-start-error":on,"cache-error":sn,"file-watch-error":an,"request-error":cn,"service-overloaded":un,"semaphore-timeout":ln,"circuit-breaker-open":dn,"cache-path-mismatch":gn,"network-error":fn,"api-client-error":pn,"token-storage-error":yn,"cache-invariant-violation":mn,"release-not-found":En,"fallback-exhausted":Rn};var hn=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"}),_n=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"}),Tn=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"}),xn=i({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),Sn=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"}),Cn=i({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),We={"client-boundary-violation":hn,"server-only-in-client":_n,"client-only-in-server":Tn,"invalid-use-client":xn,"invalid-use-server":Sn,"rsc-payload-error":Cn};var An=i({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),bn=i({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),On=i({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),In=i({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),Nn=i({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),Xe={"hmr-error":An,"dev-server-error":bn,"fast-refresh-error":On,"error-overlay-error":In,"source-map-error":Nn};var Dn=i({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),wn=i({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),Mn=i({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),Ln=i({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),Pn=i({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Hn=i({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),Un=i({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),vn=i({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),kn=i({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),$n=i({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Fn=i({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Vn=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 "}),qe={"deployment-error":Dn,"platform-error":wn,"env-var-missing":Mn,"production-build-required":Ln,"environment-not-found":Pn,"release-missing-version":Hn,"release-build-timeout":Un,"deployment-verification-timeout":vn,"push-receipt-missing":kn,"source-digest-mismatch":$n,"preview-hostname-too-long":Fn,"branch-not-found":Vn};var Bn=i({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Gn=i({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),jn=i({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),zn=i({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Yn=i({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Kn=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"}),Wn=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"}),Je={"agent-error":Bn,"agent-not-found":Gn,"agent-timeout":jn,"agent-intent-error":zn,"orchestration-error":Yn,"cost-limit-exceeded":Kn,"tool-id-conflict":Wn};var Os=Ve(Be,Ge,je,ze,Ye,Ke,We,Xe,qe,Je,Fe);var Xn=[{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 Xn.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function Jn(){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||!Jn())))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 Zn(e,t){if(t.type!=="slot")return;let r=L(e,t.id);r.innerHTML=M(String(t.html??""))}function Ze(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"){Zn(e,u);try{to(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function Qn(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 Qe(e,t=document,r){let n="body"in e?e:null,o=n?.body??e;if(!o)return;n&&F(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,Qn(r)]):await c;if(g){d=!0;break}u+=a.decode(f,{stream:!0}),u=Ze(t,u)}u&&Ze(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 eo(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 to(e,t){let r=eo(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 ro=new Set(["server","client","html","fragment"]);function et(e){if(!e)return[];try{let t=JSON.parse(e);return oo(t)?t.nodes:[]}catch{return[]}}async function ge(e,t,r){return await Promise.all(e.map(n=>no(n,t,r)))}async function no(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await ge(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 oo(e){return!de(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>tt(t,0))}function tt(e,t){return t>100||!de(e)||!ro.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&&!de(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>tt(r,t+1))}function de(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function io(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:io(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var so="Unknown dependency snapshot",ao="export default null; // Unknown dependency snapshot",fe="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function co(){return globalThis}async function uo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===so||t===ao}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await uo(e))return!1;let r=co();if(r[fe])return!0;r[fe]=!0;try{t()}catch{return delete r[fe],!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 lo=100;function go(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=lo){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 rt(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 fo(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 po(e){return et(e.dataset?.rscChildren)}function yo(e){return"/_veryfront/rsc/manifest"}function mo(e){return D(e)}async function Eo(e=document){try{let t=S(e),r=await fetch(yo(t),{headers:mo(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function nt(e,t,r,n={}){let o=Ro(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{go(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 Ro(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 ho(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 ot(e=document){let t=null;try{t=await Eo(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=ho(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=V(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=rt(g);if(!f)continue;let R=await nt(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=fo(c),b=po(c),it=await ge(b,{Fragment:u.Fragment,createElement(H,Z,...U){return u.createElement(H,Z,...U)}},async H=>{let Z=t.modules.find(at=>at.id===H),U=t.components?.[H],ye=Z?.clientRef??(U?`${U}#default`:void 0);if(!ye)return null;let Q=rt(ye);if(!Q)return null;let ee=await nt(t,Q,o,{releaseAssetModules:s});if(!ee)return null;let me=ee[Q.exportName]??ee.default;return typeof me=="function"?me:null}),st=await W(u.createElement(P,J,...it),n,e);h.render(st),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)}}async function _o(){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 To=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)||To.has(e.tagName.toUpperCase())}function xo(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function So(e,t){return e===t}function Co(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 Ao(e,t){for(let r of e){let n=[...r.hasAttribute(le)?[r]:[],...r.querySelectorAll(`[${le}]`)];for(let o of n)t.contains(o)||o.remove()}}function bo(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function Oo(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function Io(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function No(e){return e==="rsc-module"}function Do(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function wo(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function Mo(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 Qe(r,document,n.signal),"success"}catch(r){return l.debug("tryStream failed",r),"failure"}}async function q(){try{await ot(document)}catch(e){l.debug("hydration failed",e)}}async function Lo(e,t,r){try{let{React:n,ReactDOM:o}=await _o(),s=wo(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=xo(d,document.body),g=So(c,document.body)?Co(d,document.body):c;Ao(d,g);let f=await W(n.createElement(u,{}),r);return No(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 Po(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(F(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 Ho(){try{let e=S(document),t=Do(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(Io()){await q();return}let r=e?.pagePath,n=V(e);if(r){if(bo(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await Lo(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!Oo(document,e))return;let o=await Mo(t,e);if(o==="snapshot-conflict")return;if(o==="success"){await q();return}let s=await Po(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=()=>{Ho()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{Ho as boot,wo as buildPageHydrationModuleUrl,Do as buildRSCTransportQuery,Ao as retireAbandonedHeadOwnerMarkers,xo as selectHydrationRoot,Oo as shouldAttemptRSCTransport,Io as shouldHydrateOnly,No as shouldRenderPageComponent,bo as shouldUsePageRendererHydration,So as shouldWrapPageHydrationRoot};\n'; export const CLIENT_DOM_BUNDLE: string = - 'var he=Object.defineProperty;var _e=(e,r,t)=>r in e?he(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var E=(e,r,t)=>_e(e,typeof r!="symbol"?r+"":r,t);var S={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"},Jr={debug:S.gray,info:S.green,warn:S.yellow,error:S.red};var p="[REDACTED]",m=Reflect.apply;var P=RegExp.prototype.exec,R=RegExp.prototype[Symbol.replace],Qr=String.prototype.charCodeAt,H=String.prototype.slice,xe=String.prototype.toLowerCase,Te=/[^a-z0-9]/g;function N(e){let r=m(xe,e,[]);return m(R,Te,[r,""])}function A(e,r,t){return t===void 0?m(H,e,[r]):m(H,e,[r,t])}var Se=["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"],Ae=512,Ie=128,T=new Map;function V(e){let r=e.length<=Ie;if(r){let s=T.get(e);if(s!==void 0)return s}let t=N(e),o=Se.some(s=>t.includes(s));if(r){if(T.size>=Ae){let s=T.keys().next().value;s!==void 0&&T.delete(s)}T.set(e,o)}return o}var Oe=["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"],be=new Set(Oe.map(N)),Ce=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Ne=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,De=3;function we(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function Me(e){if(!e)return!1;let r=e.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function F(e){return Me(e)||e==="_"||e==="$"}function Le(e){if(!e)return!1;let r=e.charCodeAt(0);return F(e)||r>=48&&r<=57||e==="."||e==="-"}function G(e,r){let t=r,o=e[t]===\'"\'||e[t]==="\'"?e[t++]:"";if(!F(e[t]))return!1;for(t++;Le(e[t]);)t++;if(o){if(e[t]!==o)return!1;t++}for(;e[t]===" "||e[t]==="\t";)t++;return e[t]===":"||e[t]==="="}function B(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||we(e)}function j(e,r){let t=r;for(;t=e.length||G(e,t)}function Ue(e,r){let t=r,o=!0;if(e.startsWith(p,r)){let g=r+p.length;if(k(e,g))return{end:g,replacement:p};t=g,o=!1}let s=o&&(e[t]===\'"\'||e[t]==="\'"||e[t]==="`")?e[t]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=t;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:e.length,replacement:a()};if(c.pop(),g++,c.length===0&&k(e,g))return{end:g,replacement:a()};continue}if(c.length>0||!B(f)){g++;continue}let x=g;if(g=j(e,g),g>=e.length||G(e,g))return{end:x,replacement:a()}}return{end:e.length,replacement:a()}}function $(e,r,t,o){let s=0,i="";for(let a=m(P,r,[e]);a;a=m(P,r,[e])){let c=a[t];if(!V(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&e.startsWith(p,d)&&e[g]==="#")continue;let f=Ue(e,d);i+=A(e,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?e:i+A(e,s)}function ve(e,r,t){let o=t.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${A(t,0,o)}`,i=e==="//"?`https://${s}`:`${e}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function Pe(e){let r=e;for(let t=0;t{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=A(s,0,i);return`${o}${a}:${p}@`}]);return r=m(R,Ne,[r,(t,o,s,i)=>ve(o,s,i)?t:`${o}${s}:${p}@`]),r=m(R,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(t,o,s,i)=>{let a=Pe(s);return be.has(N(a))||V(a)?`${o}${s}=${p}`:t}]),r=m(R,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(t,o,s)=>`${o}${s}${p}`]),r=m(R,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(t,o)=>`${o}${p}`]),r=m(R,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(t,o,s)=>`${o}${s}${p}`]),r=$(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=$(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var He=2048;var on=64*1024,ke=256,$e="https://veryfront.com/docs/errors/",z="...[truncated]",w="unknown-error";function Y(e,r){if(e.length<=r)return e;let t=Math.max(0,r-z.length);return`${Ve(e,t)}${z}`}function Ve(e,r){let t=e.slice(0,r),o=t.charCodeAt(t.length-1);return o>=55296&&o<=56319&&(t=t.slice(0,-1)),t}function Fe(e){let r="";for(let t=0;t=55296&&o<=56319){let s=e.charCodeAt(t+1);s>=56320&&s<=57343?(r+=e.slice(t,t+2),t++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":e.charAt(t)}return r}function h(e){return typeof e!="string"?p:Y(D(e),He)}function Ge(e){let r=typeof e=="string"?D(e):w,t=Y(r||w,ke),o=Fe(t);return o==="."||o===".."?w:o}function I(e){let r=encodeURIComponent(Ge(e));return`${$e}${r}`}var Be=Object.freeze,je=Object.getOwnPropertyDescriptors,K=Number.isFinite,q=new WeakSet,ze=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(e){let r={...e},t={...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 M(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 Be(t)}var M=class extends Error{constructor(t,o){super(t);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");q.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 t=W(this);return t?{type:I(t.slug),title:h(t.title),status:t.status,detail:t.detail===void 0?void 0:h(t.detail),instance:t.instance===void 0?void 0:h(t.instance),category:t.category,suggestion:t.suggestion===void 0?void 0:h(t.suggestion),cause:typeof t.cause=="string"?h(t.cause):void 0}:{type:I("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let t=W(this);return I(t?.slug??"unknown-error")}};function X(e){return typeof e=="object"&&e!==null&&q.has(e)}function W(e){return X(e)?Ye(e):null}function Ye(e){try{if(!X(e))return null;let r=je(e),t=Re=>{let C=r[Re];return C&&"value"in C?C.value:void 0},o=t("slug"),s=t("category"),i=t("status"),a=t("title"),c=t("message"),d=t("suggestion"),u=t("exitCode"),g=t("detail"),f=t("cause"),x=t("instance"),ye=t("context"),b=t("stack");return typeof o!="string"||!ze.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"||x!==void 0&&typeof x!="string"||b!==void 0&&typeof b!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:x,context:ye,stack:b}}catch{return null}}var Ke=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),We=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),qe=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Xe=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Je=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Ze=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Qe=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),et=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),tt=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),L=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),J=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),rt=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),Z={"unknown-error":Ke,"authentication-required":We,"permission-denied":qe,"file-not-found":Xe,"resource-not-found":Je,"invalid-argument":Ze,"timeout-error":Qe,"initialization-error":et,"not-supported":tt,"security-violation":L,"input-validation-failed":J,"project-source-empty":rt};var dn=2*1024*1024;var fn=64*1024,pn=1024*1024,En=1024*1024;var mn=new TextEncoder;var xn=64*1024,Tn=1024*1024,Sn=new TextEncoder;var nt=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function ot(e,...r){let t=Object.create(null),o=e.charAt(0).toUpperCase()+e.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(t,i))throw new Error(`Duplicate ${e} slug "${i}"`);t[i]=a}return Object.freeze(t)}function Q(...e){for(let r of e)for(let t of Object.values(r)){if(typeof t.slug!="string"||t.slug.length<3||t.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(t.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${t.slug}"`);if(typeof t.category!="string"||!nt.has(t.category))throw new TypeError(`Registered error has unknown category "${t.category}"`);if(!Number.isInteger(t.status)||t.status<400||t.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${t.status}`);if(typeof t.title!="string"||t.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(t.suggestion!==void 0&&(typeof t.suggestion!="string"||t.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return ot("error registry",...e)}var st=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"}),it=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),at=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ct=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),ut=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),lt=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),gt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),dt=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),ft=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),pt=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"}),Et=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"}),ee={"config-not-found":st,"config-invalid":it,"config-parse-error":at,"config-validation-error":ct,"config-type-error":ut,"import-map-invalid":lt,"cors-config-invalid":gt,"config-validation-failed":dt,"webhook-config-invalid":ft,"schedule-config-invalid":pt,"trigger-config-invalid":Et};var mt=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),yt=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),Rt=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),ht=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),_t=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),xt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Tt=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),St=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),te={"build-failed":mt,"bundle-error":yt,"typescript-error":Rt,"mdx-compile-error":ht,"asset-optimization-error":_t,"ssg-generation-error":xt,"sourcemap-error":Tt,"compilation-error":St};var At=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),It=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Ot=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),bt=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Ct=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"}),Nt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),Dt=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),wt=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"}),Mt=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"}),Lt=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"}),re={"hydration-mismatch":At,"render-error":It,"component-error":Ot,"layout-not-found":bt,"page-not-found":Ct,"api-error":Nt,"middleware-error":Dt,"trigger-target-not-found":wt,"trigger-execution-failed":Mt,"trigger-not-supported":Lt};var Ut=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),vt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Pt=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Ht=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),kt=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),$t=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),ne={"route-conflict":Ut,"invalid-route-file":vt,"route-handler-invalid":Pt,"dynamic-route-error":Ht,"route-params-error":kt,"api-route-error":$t};var Vt=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"}),Ft=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Gt=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),Bt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),jt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),zt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),oe={"module-not-found":Vt,"import-resolution-error":Ft,"circular-dependency":Gt,"invalid-import":Bt,"dependency-missing":jt,"version-mismatch":zt};var Yt=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"}),Kt=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),Wt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),qt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Xt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Jt=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Zt=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),Qt=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),er=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),tr=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),rr=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),nr=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),or=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),sr=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"}),ir=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),se={"port-in-use":Yt,"server-start-error":Kt,"cache-error":Wt,"file-watch-error":qt,"request-error":Xt,"service-overloaded":Jt,"semaphore-timeout":Zt,"circuit-breaker-open":Qt,"cache-path-mismatch":er,"network-error":tr,"api-client-error":rr,"token-storage-error":nr,"cache-invariant-violation":or,"release-not-found":sr,"fallback-exhausted":ir};var ar=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"}),cr=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"}),ur=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"}),lr=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),gr=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"}),dr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),ie={"client-boundary-violation":ar,"server-only-in-client":cr,"client-only-in-server":ur,"invalid-use-client":lr,"invalid-use-server":gr,"rsc-payload-error":dr};var fr=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),pr=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),Er=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),mr=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),yr=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),ae={"hmr-error":fr,"dev-server-error":pr,"fast-refresh-error":Er,"error-overlay-error":mr,"source-map-error":yr};var Rr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),hr=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),_r=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),xr=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),Tr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Sr=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),Ar=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Ir=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),Or=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),br=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Cr=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Nr=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 "}),ce={"deployment-error":Rr,"platform-error":hr,"env-var-missing":_r,"production-build-required":xr,"environment-not-found":Tr,"release-missing-version":Sr,"release-build-timeout":Ar,"deployment-verification-timeout":Ir,"push-receipt-missing":Or,"source-digest-mismatch":br,"preview-hostname-too-long":Cr,"branch-not-found":Nr};var Dr=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),wr=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Mr=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Lr=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Ur=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),vr=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"}),Pr=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"}),ue={"agent-error":Dr,"agent-not-found":wr,"agent-timeout":Mr,"agent-intent-error":Lr,"orchestration-error":Ur,"cost-limit-exceeded":vr,"tool-id-conflict":Pr};var ao=Q(ee,te,re,ne,oe,se,ie,ae,ce,ue,Z);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 kr(){return Hr.map(({source:e,flags:r,name:t})=>({pattern:new RegExp(e,r),name:t}))}function $r(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function le(e,r={}){let{allowInlineScripts:t=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of kr())if(!(t&&a==="inline script")&&(i.lastIndex=0,!!i.test(e)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!$r())))throw L.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}var _=class{constructor(r,t){E(this,"prefix",r);E(this,"level",t)}log(r,t,o,...s){this.level>r||t?.(o,...s)}debug(r,...t){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...t)}info(r,...t){this.log(1,console.log,`[${this.prefix}] ${r}`,...t)}warn(r,...t){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...t)}error(r,...t){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...t)}};function Vr(){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 O=Vr(),y=new _("RSC",O),Io=new _("PREFETCH",O),Oo=new _("HYDRATE",O),bo=new _("VERYFRONT",O);var Do=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Lo=16*1024*1024;var l="/_veryfront",U={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 Gr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},vo=Gr.CACHE;var Po={HMR_RUNTIME:ge.HMR_RUNTIME,ERROR_OVERLAY:ge.ERROR_OVERLAY};var Br=U.RSC,jr=U.FS,de="veryfront-hydration-data",fe="rsc-root",v="x-veryfront-dependency-pins";var Jo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function pe(e,r){if(!r?.startsWith("on:"))return!1;try{let t=e.getElementById(de);if(!t)return!1;let o=JSON.parse(t.textContent||"{}");return o.dependencyPinningCacheKey=r,t.textContent=JSON.stringify(o),!0}catch(t){return y.debug("hydration dependency snapshot seed failed",t),!1}}function me(e,r){let t=r==="root"?fe:`rsc-slot-${r}`,o=e.getElementById(t);if(o)return o;let s=e.createElement("div");return s.id=t,e.body.appendChild(s),s}function Yr(e,r){if(r.type!=="slot")return;let t=me(e,r.id);t.innerHTML=le(String(r.html??""))}function Ee(e,r){let t=r.split(`\n`),o=t.pop()??"";for(let s of t){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){y.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"){Yr(e,c);try{qr(e,c.id||"root")}catch(d){y.debug("[client-dom] hydration optional failed",d)}}}return o}function Kr(e){return new Promise((r,t)=>{let o=()=>t(new DOMException("aborted","AbortError"));if(e.aborted){o();return}e.addEventListener("abort",o,{once:!0})})}async function Es(e,r=document,t){let o="body"in e?e:null,s=o?.body??e;if(!s)return;o&&pe(r,o.headers.get(v));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(t?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=t?await Promise.race([u,Kr(t)]):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"||y.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||y.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){y.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){y.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){y.debug("[client-dom] response.body.cancel failed",u)}}}function Wr(e,r){let t=me(e,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(t),o}function qr(e,r){let t=Wr(e,r);for(let o of t){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",y.debug("[client-dom] marked for hydration",s))}}export{Es as consumeNdjsonStream,me as getContainer};\n'; + 'var _e=Object.defineProperty;var xe=(e,r,t)=>r in e?_e(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var E=(e,r,t)=>xe(e,typeof r!="symbol"?r+"":r,t);var S={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"},rn={debug:S.gray,info:S.green,warn:S.yellow,error:S.red};var p="[REDACTED]",m=Reflect.apply;var P=RegExp.prototype.exec,R=RegExp.prototype[Symbol.replace],on=String.prototype.charCodeAt,H=String.prototype.slice,Te=String.prototype.toLowerCase,Se=/[^a-z0-9]/g;function N(e){let r=m(Te,e,[]);return m(R,Se,[r,""])}function A(e,r,t){return t===void 0?m(H,e,[r]):m(H,e,[r,t])}var Ae=["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"],Ie=512,Oe=128,T=new Map;function V(e){let r=e.length<=Oe;if(r){let s=T.get(e);if(s!==void 0)return s}let t=N(e),o=Ae.some(s=>t.includes(s));if(r){if(T.size>=Ie){let s=T.keys().next().value;s!==void 0&&T.delete(s)}T.set(e,o)}return o}var be=["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"],Ce=new Set(be.map(N)),Ne=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,De=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,we=3;function Me(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function Le(e){if(!e)return!1;let r=e.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function F(e){return Le(e)||e==="_"||e==="$"}function Ue(e){if(!e)return!1;let r=e.charCodeAt(0);return F(e)||r>=48&&r<=57||e==="."||e==="-"}function G(e,r){let t=r,o=e[t]===\'"\'||e[t]==="\'"?e[t++]:"";if(!F(e[t]))return!1;for(t++;Ue(e[t]);)t++;if(o){if(e[t]!==o)return!1;t++}for(;e[t]===" "||e[t]==="\t";)t++;return e[t]===":"||e[t]==="="}function B(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||Me(e)}function j(e,r){let t=r;for(;t=e.length||G(e,t)}function ve(e,r){let t=r,o=!0;if(e.startsWith(p,r)){let g=r+p.length;if(k(e,g))return{end:g,replacement:p};t=g,o=!1}let s=o&&(e[t]===\'"\'||e[t]==="\'"||e[t]==="`")?e[t]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=t;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:e.length,replacement:a()};if(c.pop(),g++,c.length===0&&k(e,g))return{end:g,replacement:a()};continue}if(c.length>0||!B(f)){g++;continue}let x=g;if(g=j(e,g),g>=e.length||G(e,g))return{end:x,replacement:a()}}return{end:e.length,replacement:a()}}function $(e,r,t,o){let s=0,i="";for(let a=m(P,r,[e]);a;a=m(P,r,[e])){let c=a[t];if(!V(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&e.startsWith(p,d)&&e[g]==="#")continue;let f=ve(e,d);i+=A(e,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?e:i+A(e,s)}function Pe(e,r,t){let o=t.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${A(t,0,o)}`,i=e==="//"?`https://${s}`:`${e}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function He(e){let r=e;for(let t=0;t{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=A(s,0,i);return`${o}${a}:${p}@`}]);return r=m(R,De,[r,(t,o,s,i)=>Pe(o,s,i)?t:`${o}${s}:${p}@`]),r=m(R,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(t,o,s,i)=>{let a=He(s);return Ce.has(N(a))||V(a)?`${o}${s}=${p}`:t}]),r=m(R,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(t,o,s)=>`${o}${s}${p}`]),r=m(R,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(t,o)=>`${o}${p}`]),r=m(R,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(t,o,s)=>`${o}${s}${p}`]),r=$(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=$(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var ke=2048;var ln=64*1024,$e=256,Ve="https://veryfront.com/docs/errors/",z="...[truncated]",w="unknown-error";function Y(e,r){if(e.length<=r)return e;let t=Math.max(0,r-z.length);return`${Fe(e,t)}${z}`}function Fe(e,r){let t=e.slice(0,r),o=t.charCodeAt(t.length-1);return o>=55296&&o<=56319&&(t=t.slice(0,-1)),t}function Ge(e){let r="";for(let t=0;t=55296&&o<=56319){let s=e.charCodeAt(t+1);s>=56320&&s<=57343?(r+=e.slice(t,t+2),t++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":e.charAt(t)}return r}function h(e){return typeof e!="string"?p:Y(D(e),ke)}function Be(e){let r=typeof e=="string"?D(e):w,t=Y(r||w,$e),o=Ge(t);return o==="."||o===".."?w:o}function I(e){let r=encodeURIComponent(Be(e));return`${Ve}${r}`}var je=Object.freeze,ze=Object.getOwnPropertyDescriptors,K=Number.isFinite,X=new WeakSet,Ye=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(e){let r={...e},t={...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 M(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 je(t)}var M=class extends Error{constructor(t,o){super(t);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 t=W(this);return t?{type:I(t.slug),title:h(t.title),status:t.status,detail:t.detail===void 0?void 0:h(t.detail),instance:t.instance===void 0?void 0:h(t.instance),category:t.category,suggestion:t.suggestion===void 0?void 0:h(t.suggestion),cause:typeof t.cause=="string"?h(t.cause):void 0}:{type:I("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let t=W(this);return I(t?.slug??"unknown-error")}};function q(e){return typeof e=="object"&&e!==null&&X.has(e)}function W(e){return q(e)?Ke(e):null}function Ke(e){try{if(!q(e))return null;let r=ze(e),t=he=>{let C=r[he];return C&&"value"in C?C.value:void 0},o=t("slug"),s=t("category"),i=t("status"),a=t("title"),c=t("message"),d=t("suggestion"),u=t("exitCode"),g=t("detail"),f=t("cause"),x=t("instance"),Re=t("context"),b=t("stack");return typeof o!="string"||!Ye.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"||x!==void 0&&typeof x!="string"||b!==void 0&&typeof b!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:x,context:Re,stack:b}}catch{return null}}var We=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Xe=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),qe=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Je=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Ze=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Qe=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),et=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),tt=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),rt=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),L=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),J=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),nt=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),Z={"unknown-error":We,"authentication-required":Xe,"permission-denied":qe,"file-not-found":Je,"resource-not-found":Ze,"invalid-argument":Qe,"timeout-error":et,"initialization-error":tt,"not-supported":rt,"security-violation":L,"input-validation-failed":J,"project-source-empty":nt};var yn=2*1024*1024;var Rn=64*1024,hn=1024*1024,_n=1024*1024;var xn=new TextEncoder;var On=64*1024,bn=1024*1024,Cn=new TextEncoder;var ot=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function st(e,...r){let t=Object.create(null),o=e.charAt(0).toUpperCase()+e.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(t,i))throw new Error(`Duplicate ${e} slug "${i}"`);t[i]=a}return Object.freeze(t)}function Q(...e){for(let r of e)for(let t of Object.values(r)){if(typeof t.slug!="string"||t.slug.length<3||t.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(t.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${t.slug}"`);if(typeof t.category!="string"||!ot.has(t.category))throw new TypeError(`Registered error has unknown category "${t.category}"`);if(!Number.isInteger(t.status)||t.status<400||t.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${t.status}`);if(typeof t.title!="string"||t.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(t.suggestion!==void 0&&(typeof t.suggestion!="string"||t.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return st("error registry",...e)}var it=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"}),at=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),ct=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ut=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),lt=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),gt=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),dt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),ft=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),pt=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),Et=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"}),mt=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"}),ee={"config-not-found":it,"config-invalid":at,"config-parse-error":ct,"config-validation-error":ut,"config-type-error":lt,"import-map-invalid":gt,"cors-config-invalid":dt,"config-validation-failed":ft,"webhook-config-invalid":pt,"schedule-config-invalid":Et,"trigger-config-invalid":mt};var yt=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Rt=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),ht=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),_t=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),xt=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Tt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),St=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),At=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),te={"build-failed":yt,"bundle-error":Rt,"typescript-error":ht,"mdx-compile-error":_t,"asset-optimization-error":xt,"ssg-generation-error":Tt,"sourcemap-error":St,"compilation-error":At};var It=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Ot=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),bt=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Ct=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Nt=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"}),Dt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),wt=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Mt=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"}),Lt=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"}),Ut=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"}),re={"hydration-mismatch":It,"render-error":Ot,"component-error":bt,"layout-not-found":Ct,"page-not-found":Nt,"api-error":Dt,"middleware-error":wt,"trigger-target-not-found":Mt,"trigger-execution-failed":Lt,"trigger-not-supported":Ut};var vt=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Pt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Ht=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),kt=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),$t=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Vt=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),ne={"route-conflict":vt,"invalid-route-file":Pt,"route-handler-invalid":Ht,"dynamic-route-error":kt,"route-params-error":$t,"api-route-error":Vt};var Ft=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"}),Gt=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Bt=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),jt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),zt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Yt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),oe={"module-not-found":Ft,"import-resolution-error":Gt,"circular-dependency":Bt,"invalid-import":jt,"dependency-missing":zt,"version-mismatch":Yt};var Kt=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"}),Wt=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),Xt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),qt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Jt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Zt=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Qt=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),er=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),tr=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),rr=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),nr=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),or=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),sr=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),ir=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"}),ar=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),se={"port-in-use":Kt,"server-start-error":Wt,"cache-error":Xt,"file-watch-error":qt,"request-error":Jt,"service-overloaded":Zt,"semaphore-timeout":Qt,"circuit-breaker-open":er,"cache-path-mismatch":tr,"network-error":rr,"api-client-error":nr,"token-storage-error":or,"cache-invariant-violation":sr,"release-not-found":ir,"fallback-exhausted":ar};var cr=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"}),ur=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"}),lr=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"}),gr=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),dr=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"}),fr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),ie={"client-boundary-violation":cr,"server-only-in-client":ur,"client-only-in-server":lr,"invalid-use-client":gr,"invalid-use-server":dr,"rsc-payload-error":fr};var pr=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),Er=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),mr=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),yr=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),Rr=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),ae={"hmr-error":pr,"dev-server-error":Er,"fast-refresh-error":mr,"error-overlay-error":yr,"source-map-error":Rr};var hr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),_r=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),xr=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),Tr=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),Sr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Ar=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),Ir=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Or=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),br=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),Cr=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Nr=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Dr=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 "}),ce={"deployment-error":hr,"platform-error":_r,"env-var-missing":xr,"production-build-required":Tr,"environment-not-found":Sr,"release-missing-version":Ar,"release-build-timeout":Ir,"deployment-verification-timeout":Or,"push-receipt-missing":br,"source-digest-mismatch":Cr,"preview-hostname-too-long":Nr,"branch-not-found":Dr};var wr=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Mr=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Lr=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Ur=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),vr=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Pr=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"}),Hr=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"}),ue={"agent-error":wr,"agent-not-found":Mr,"agent-timeout":Lr,"agent-intent-error":Ur,"orchestration-error":vr,"cost-limit-exceeded":Pr,"tool-id-conflict":Hr};var fo=Q(ee,te,re,ne,oe,se,ie,ae,ce,ue,Z);var kr=[{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 $r(){return kr.map(({source:e,flags:r,name:t})=>({pattern:new RegExp(e,r),name:t}))}function Vr(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function le(e,r={}){let{allowInlineScripts:t=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of $r())if(!(t&&a==="inline script")&&(i.lastIndex=0,!!i.test(e)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!Vr())))throw L.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}var _=class{constructor(r,t){E(this,"prefix",r);E(this,"level",t)}log(r,t,o,...s){this.level>r||t?.(o,...s)}debug(r,...t){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...t)}info(r,...t){this.log(1,console.log,`[${this.prefix}] ${r}`,...t)}warn(r,...t){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...t)}error(r,...t){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...t)}};function Fr(){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 O=Fr(),y=new _("RSC",O),Do=new _("PREFETCH",O),wo=new _("HYDRATE",O),Mo=new _("VERYFRONT",O);var vo=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Gr=5e3,Br=1e4,ko=16*1024*1024,jr=5e3;var zr=100;var Yr=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),$o=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),Vo=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Gr,api:3e4,ssr:Br,hmr:3e4,sandbox:jr}),cache:Object.freeze({jit:Object.freeze({maxSize:zr,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:Yr})});var l="/_veryfront",U={RSC:`${l}/rsc/`,FS:`${l}/fs/`,MODULES:`${l}/modules/`,PAGES:`${l}/pages/`,DATA:`${l}/data/`,LIB:`${l}/lib/`,CHUNKS:`${l}/chunks/`,CLIENT:`${l}/client/`},de={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 Kr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},Go=Kr.CACHE;var Bo={HMR_RUNTIME:de.HMR_RUNTIME,ERROR_OVERLAY:de.ERROR_OVERLAY};var Wr=U.RSC,Xr=U.FS,fe="veryfront-hydration-data",pe="rsc-root",v="x-veryfront-dependency-pins";var os=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function Ee(e,r){if(!r?.startsWith("on:"))return!1;try{let t=e.getElementById(fe);if(!t)return!1;let o=JSON.parse(t.textContent||"{}");return o.dependencyPinningCacheKey=r,t.textContent=JSON.stringify(o),!0}catch(t){return y.debug("hydration dependency snapshot seed failed",t),!1}}function ye(e,r){let t=r==="root"?pe:`rsc-slot-${r}`,o=e.getElementById(t);if(o)return o;let s=e.createElement("div");return s.id=t,e.body.appendChild(s),s}function Jr(e,r){if(r.type!=="slot")return;let t=ye(e,r.id);t.innerHTML=le(String(r.html??""))}function me(e,r){let t=r.split(`\n`),o=t.pop()??"";for(let s of t){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){y.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"){Jr(e,c);try{en(e,c.id||"root")}catch(d){y.debug("[client-dom] hydration optional failed",d)}}}return o}function Zr(e){return new Promise((r,t)=>{let o=()=>t(new DOMException("aborted","AbortError"));if(e.aborted){o();return}e.addEventListener("abort",o,{once:!0})})}async function Ts(e,r=document,t){let o="body"in e?e:null,s=o?.body??e;if(!s)return;o&&Ee(r,o.headers.get(v));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(t?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=t?await Promise.race([u,Zr(t)]):await u;if(g){d=!0;break}c+=a.decode(f,{stream:!0}),c=me(r,c)}c&&me(r,`${c}\n`)}catch(u){throw u instanceof Error&&u.name==="AbortError"||y.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||y.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){y.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){y.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){y.debug("[client-dom] response.body.cancel failed",u)}}}function Qr(e,r){let t=ye(e,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(t),o}function en(e,r){let t=Qr(e,r);for(let o of t){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",y.debug("[client-dom] marked for hydration",s))}}export{Ts as consumeNdjsonStream,ye as getContainer};\n'; diff --git a/src/server/shared/renderer/adapter.test.ts b/src/server/shared/renderer/adapter.test.ts index cb91dccb31..72311387c6 100644 --- a/src/server/shared/renderer/adapter.test.ts +++ b/src/server/shared/renderer/adapter.test.ts @@ -8,6 +8,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { Renderer, RendererOptions } from "#veryfront/rendering/renderer.ts"; +import { prepareDeclarativeConfigContext } from "#veryfront/config/declarative-evaluator.ts"; +import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; import { destroyRendererAdapter, getRendererForProject, @@ -20,8 +22,16 @@ import { // --------------------------------------------------------------------------- /** Minimal mock Renderer that records calls. */ -function createMockRenderer(): Renderer & { calls: Record } { - const calls: Record = { +type RendererCallCounts = { + renderPage: number; + resolvePageData: number; + getAllPages: number; + clearCache: number; + destroy: number; +}; + +function createMockRenderer(): Renderer & { calls: RendererCallCounts } { + const calls: RendererCallCounts = { renderPage: 0, resolvePageData: 0, getAllPages: 0, @@ -55,7 +65,7 @@ function createMockRenderer(): Renderer & { calls: Record } { }, // deno-lint-ignore no-explicit-any async initialize(_opts?: any) {}, - } as unknown as Renderer & { calls: Record }; + } as unknown as Renderer & { calls: RendererCallCounts }; } /** @@ -452,6 +462,70 @@ describe("RendererAdapter with RendererInitializer", () => { assertEquals(pages, ["/"]); }); + it("evaluates shared multi-project config through the request's hosted context", async () => { + const ctx = stubHandlerContext(); + ctx.enriched = undefined; + ctx.config = undefined; + ctx.isLocalProject = false; + ctx.projectDir = "/tmp/hosted-project"; + ctx.resolvedEnvironment = "preview"; + ctx.requestContext = { branch: "feature/hosted-render", mode: "preview" }; + + const sourceContext = { + productionMode: false, + branch: "feature/hosted-render", + } as const; + ctx.prepareHostedConfigContext = async () => ({ + sourceContext, + preparedContext: await prepareDeclarativeConfigContext({ + environmentName: "preview", + environment: { TENANT: "tenant-value" }, + }), + }); + + const fs = { + isVeryfrontAdapter: () => true, + getUnderlyingAdapter: () => ({}), + isMultiProjectMode: () => true, + runWithContext: ( + projectSlug: string, + token: string, + fn: () => Promise, + projectId?: string, + opts?: Record, + ) => + runWithRequestContext( + { projectSlug, token, projectId, ...opts }, + fn as () => Promise, + ), + exists: () => Promise.reject(new Error("hosted config must not probe exists")), + readFile: (path: string) => { + if (path !== "/veryfront.config.ts") { + return Promise.reject( + Object.assign(new Error(`File not found: ${path}`), { code: "ENOENT" }), + ); + } + return Promise.resolve(` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: \`\${environmentName}:\${getEnv("TENANT") ?? "missing"}\`, + })); + `); + }, + readDir: async function* () {}, + stat: () => Promise.resolve({ isFile: false, isDirectory: false }), + }; + ctx.adapter = { + fs, + env: { get: () => undefined, set: () => {}, delete: () => {}, toObject: () => ({}) }, + } as unknown as any; + + await getRendererForProject(ctx); + + assertEquals(ctx.enriched !== undefined, true); + assertEquals(ctx.enriched.config.title, "preview:tenant-value"); + }); + it("derives projectId from projectDir when no explicit id", async () => { const ctx = stubHandlerContext(); ctx.enriched = undefined; diff --git a/src/server/shared/renderer/adapter.ts b/src/server/shared/renderer/adapter.ts index 1e0cad2a7b..44555bce2e 100644 --- a/src/server/shared/renderer/adapter.ts +++ b/src/server/shared/renderer/adapter.ts @@ -10,6 +10,7 @@ import { rendererLogger } from "#veryfront/utils"; import { getConfig, type VeryfrontConfig } from "#veryfront/config"; +import { getHostedConfig } from "#veryfront/config/loader.ts"; import { getEnvBoolean, getEnvString } from "#veryfront/compat/process.ts"; import type { HandlerContext } from "../../handlers/types.ts"; import { buildEnrichedContext } from "../../context/enriched-context.ts"; @@ -211,6 +212,28 @@ function resolveEnvironment(ctx: HandlerContext): "preview" | "production" { return ctx.requestContext?.mode ?? "preview"; } +/** + * Load project config for a handler that did not receive one. + * + * A shared multi-project runtime serves untrusted project sources, so config + * is evaluated declaratively under the identity the request already + * established. Deriving a source or environment here instead would let one + * request evaluate the same project two different ways. + */ +async function loadConfigForHandler( + ctx: HandlerContext, + cacheKey: string | undefined, +): Promise { + if (shouldUseMultiProjectContext(ctx) && ctx.prepareHostedConfigContext && cacheKey) { + return await getHostedConfig(ctx.projectDir, ctx.adapter, { + cacheKey, + ...await ctx.prepareHostedConfigContext(), + }); + } + + return await getConfig(ctx.projectDir, ctx.adapter, { cacheKey }); +} + async function createContextFromHandler(ctx: HandlerContext): Promise { // "unknown" is used only for debug logging below — actual cache keys and enriched context // use ctx.projectSlug ?? ctx.projectId ?? derivedProjectId (never "unknown"), so there @@ -233,7 +256,7 @@ async function createContextFromHandler(ctx: HandlerContext): Promise adapter.fs, @@ -440,6 +441,7 @@ describe("task/discovery", { sanitizeOps: false, sanitizeResources: false }, () const firstAdapter = createRuntimeAdapter({ "/veryfront.config.ts": [ "export default {", + ' fs: { type: "veryfront-api", veryfront: { projectSlug: "project-a" } },', ' ai: { tasks: { discovery: { paths: ["first-tasks"] } } },', "};", "", @@ -459,6 +461,7 @@ describe("task/discovery", { sanitizeOps: false, sanitizeResources: false }, () const secondAdapter = createRuntimeAdapter({ "/veryfront.config.ts": [ "export default {", + ' fs: { type: "veryfront-api", veryfront: { projectSlug: "project-b" } },', ' ai: { tasks: { discovery: { paths: ["second-tasks"] } } },', "};", "", diff --git a/src/testing/assert.ts b/src/testing/assert.ts index 3a85a53a0e..365f8a4ae8 100644 --- a/src/testing/assert.ts +++ b/src/testing/assert.ts @@ -17,7 +17,7 @@ interface AssertImpl { errorClassOrMsg?: ErrorClass | string, msgIncludesOrMsg?: string, msg?: string, - ): void; + ): unknown; assertRejects( fn: () => Promise, errorClassOrMsg?: ErrorClass | string, @@ -140,7 +140,7 @@ function createNodeAssertImpl(): AssertImpl { errorClassOrMsg?: ErrorClass | string, msgIncludesOrMsg?: string, _msg?: string, - ): void { + ): unknown { let threw = false; let error: unknown; @@ -158,6 +158,7 @@ function createNodeAssertImpl(): AssertImpl { msgIncludesOrMsg, "Expected function to throw", ); + return error; }, async assertRejects( @@ -295,8 +296,8 @@ export function assertThrows( errorClassOrMsg?: ErrorClass | string, msgIncludesOrMsg?: string, msg?: string, -): void { - impl.assertThrows(fn, errorClassOrMsg, msgIncludesOrMsg, msg); +): unknown { + return impl.assertThrows(fn, errorClassOrMsg, msgIncludesOrMsg, msg); } /** Assert that an async function rejects. */ diff --git a/src/types/server.ts b/src/types/server.ts index 7415666525..54caac1c51 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -1,5 +1,6 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { VeryfrontConfig } from "../config/schemas/index.ts"; +import type { PreparedHostedConfigContext } from "../config/loader.ts"; import type { RequestContext } from "../server/context/request-context.ts"; import type { EnrichedContext } from "../server/context/enriched-context-types.ts"; import type { ParsedDomain } from "../server/utils/domain-parser.ts"; @@ -69,6 +70,15 @@ export interface HandlerContext { isLocalProject?: boolean; /** Environment ID for per-project env var resolution (from proxy x-environment-id header) */ environmentId?: string; + /** + * Prepares this request's authenticated hosted evaluation context. + * + * Present only for shared multi-project runtimes, where project config is + * untrusted and must be evaluated declaratively. Handlers that load config + * themselves must use this rather than deriving source or environment + * identity, so every load in a request shares one identity. + */ + prepareHostedConfigContext?: () => Promise; /** Route registry for handler chain inspection (dev dashboard) */ routeRegistry?: { getHandlers(): ReadonlyArray<{ metadata: HandlerMetadata }>; diff --git a/src/utils/constants/build.ts b/src/utils/constants/build.ts index c42982e9b6..9cc5238861 100644 --- a/src/utils/constants/build.ts +++ b/src/utils/constants/build.ts @@ -1,3 +1,5 @@ +import { MAX_CSS_FILES } from "./css.ts"; + /** Default value for build concurrency. */ export const DEFAULT_BUILD_CONCURRENCY = 4; @@ -8,11 +10,14 @@ export const IMAGE_OPTIMIZATION = { MAX_DIMENSION: 32_768, MAX_OUTPUT_SIZES: 64, MAX_ENGINE_IDENTITY_CHARACTERS: 256, + PUBLIC_PATH: "/_vf/assets/images", } as const; /** Shared CSS optimization resource bounds. */ export const CSS_OPTIMIZATION = { - MAX_FILES: 10_000, + MAX_FILES: MAX_CSS_FILES, + MAX_BROWSER_QUERIES: 64, + MAX_BROWSER_QUERY_CHARACTERS: 512, MAX_PURGE_PATTERNS: 128, MAX_PURGE_SAFELIST_ENTRIES: 1_024, } as const; diff --git a/src/utils/constants/limits.ts b/src/utils/constants/limits.ts index 6db6bb91cc..56c14091cd 100644 --- a/src/utils/constants/limits.ts +++ b/src/utils/constants/limits.ts @@ -21,6 +21,8 @@ export const CACHE_MAX_ENTRIES_LARGE = 500; export const CACHE_MAX_ENTRIES_XLARGE = 1000; export const API_ROUTE_CACHE_MAX_ENTRIES = 500; export const HANDLER_CACHE_MAX_ENTRIES = 256; +/** Maximum key length accepted by the shared in-memory cache adapter. */ +export const MAX_CACHE_KEY_CHARACTERS = 16_384; export const MAX_PATH_LENGTH_CHARS = 4096; export const MAX_PORT_NUMBER = 65535; diff --git a/src/utils/constants/security.ts b/src/utils/constants/security.ts index a7f259140c..b1a6af005b 100644 --- a/src/utils/constants/security.ts +++ b/src/utils/constants/security.ts @@ -14,4 +14,8 @@ export const DIRECTORY_TRAVERSAL_PATTERN = /\.\.[\/\\]/; export const ABSOLUTE_PATH_PATTERN = /^[\/\\]/; /** Maximum value for path length. */ export const MAX_PATH_LENGTH = 4096; +/** Maximum length of one configured CSRF cookie or header name. */ +export const MAX_CSRF_NAME_LENGTH = 256; +/** Maximum safe integer accepted for a CSRF cookie Max-Age value. */ +export const MAX_CSRF_TTL_SECONDS = Number.MAX_SAFE_INTEGER; export const DEFAULT_MAX_STRING_LENGTH = 1000; diff --git a/src/utils/cors-policy-limits.ts b/src/utils/cors-policy-limits.ts new file mode 100644 index 0000000000..43c390f894 --- /dev/null +++ b/src/utils/cors-policy-limits.ts @@ -0,0 +1,76 @@ +/** + * Shared resource limits for CORS configuration and generated response headers. + * + * This module deliberately lives below both config and security so schema-time + * and runtime validation use the same contract without introducing a cycle. + */ + +export const MAX_CORS_ORIGIN_LENGTH = 2048; +export const MAX_CORS_ORIGIN_COUNT = 64; +export const MAX_CORS_ORIGIN_LIST_LENGTH = 8192; +export const MAX_CORS_TOKEN_LENGTH = 256; +export const MAX_CORS_TOKEN_COUNT = 64; +export const MAX_CORS_SERIALIZED_LIST_LENGTH = 4096; +export const MAX_CORS_MAX_AGE = Number.MAX_SAFE_INTEGER; + +/** RFC 9110 token syntax shared by schema and runtime CORS validation. */ +export const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const CORS_POLICY_RESPONSE_HEADER_PREFIX = "access-control-"; +// Origin values are header values; controls are never valid and would make +// Headers.set() throw instead of producing a deterministic CORS denial. +const LIST_SEPARATOR_LENGTH = 2; + +function isHeaderSafeByteString(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f || code > 0xff) return false; + } + return true; +} + +function serializedListLength(values: readonly string[]): number { + if (values.length === 0) return 0; + return values.reduce((total, value) => total + value.length, 0) + + (values.length - 1) * LIST_SEPARATOR_LENGTH; +} + +export function isBoundedCorsOrigin(value: unknown): value is string { + return typeof value === "string" && + value.length > 0 && + value.length <= MAX_CORS_ORIGIN_LENGTH && + value.trim() === value && + isHeaderSafeByteString(value); +} + +export function isBoundedCorsOriginList(values: readonly unknown[]): values is readonly string[] { + if (values.length === 0 || values.length > MAX_CORS_ORIGIN_COUNT) return false; + if (!values.every(isBoundedCorsOrigin)) return false; + return serializedListLength(values) <= MAX_CORS_ORIGIN_LIST_LENGTH; +} + +export function isBoundedCorsToken(value: unknown): value is string { + return typeof value === "string" && + value.length > 0 && + value.length <= MAX_CORS_TOKEN_LENGTH && + HTTP_TOKEN_PATTERN.test(value); +} + +export function isBoundedCorsTokenList(values: readonly unknown[]): values is readonly string[] { + if (values.length > MAX_CORS_TOKEN_COUNT) return false; + if (!values.every(isBoundedCorsToken)) return false; + return serializedListLength(values) <= MAX_CORS_SERIALIZED_LIST_LENGTH; +} + +export function isValidCorsMaxAge(value: unknown): value is number { + return typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0 && + value <= MAX_CORS_MAX_AGE; +} + +/** Whether a response header is reserved for the dedicated CORS policy layer. */ +export function isCorsPolicyResponseHeaderName(value: unknown): value is string { + return typeof value === "string" && + value.slice(0, CORS_POLICY_RESPONSE_HEADER_PREFIX.length).toLowerCase() === + CORS_POLICY_RESPONSE_HEADER_PREFIX; +} diff --git a/src/utils/discovery-path-policy.ts b/src/utils/discovery-path-policy.ts new file mode 100644 index 0000000000..4f8240cc71 --- /dev/null +++ b/src/utils/discovery-path-policy.ts @@ -0,0 +1,59 @@ +import { MAX_PATH_LENGTH_CHARS } from "./constants/limits.ts"; + +/** Maximum number of roots accepted for one project primitive kind. */ +export const MAX_PROJECT_DISCOVERY_DIRECTORIES = 100; + +function containsControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +/** + * Validate and normalize one project-relative discovery root. + * + * Discovery roots are configuration, not arbitrary filesystem paths: they must + * remain inside the project, use a canonical segment spelling, and be portable + * across local and virtual filesystem adapters. + */ +export function normalizeProjectRelativeDiscoveryPath(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + containsControlCharacter(value) || + /^file:\/\//i.test(value) + ) { + throw new TypeError( + `Project discovery path must be a non-empty relative path of at most ${MAX_PATH_LENGTH_CHARS} characters without control characters`, + ); + } + + const normalized = value.replaceAll("\\", "/").replace(/\/+$/, ""); + if ( + normalized.length === 0 || + normalized.startsWith("/") || + /^[A-Za-z]:/.test(normalized) || + normalized.split("/").some((segment) => + segment.length === 0 || segment === "." || segment === ".." + ) + ) { + throw new TypeError( + "Project discovery path must stay within the project and cannot contain empty, dot, or traversal segments", + ); + } + + return normalized; +} + +/** Return whether a value is a canonical project-relative discovery root. */ +export function isProjectRelativeDiscoveryPath(value: unknown): value is string { + try { + normalizeProjectRelativeDiscoveryPath(value); + return true; + } catch { + return false; + } +} diff --git a/src/utils/project-relative-path.ts b/src/utils/project-relative-path.ts new file mode 100644 index 0000000000..a70bf48b9f --- /dev/null +++ b/src/utils/project-relative-path.ts @@ -0,0 +1,126 @@ +import { isAbsolute, relative, resolve } from "#veryfront/compat/path/resolution.ts"; +import { MAX_PATH_LENGTH_CHARS } from "./constants/limits.ts"; + +function containsControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +/** Admit a path string before normalization or platform path operations. */ +export function assertBoundedPathString( + value: unknown, + label = "Path", +): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + containsControlCharacter(value) + ) { + throw new TypeError( + `${label} must be a non-empty path of at most ${MAX_PATH_LENGTH_CHARS} characters without control characters`, + ); + } + return value; +} + +/** + * Assert that a configured project path has one portable, unambiguous spelling. + * + * Configuration paths use forward slashes and are always relative to the + * project root. Rejecting normalization aliases keeps cache identities stable + * and prevents local and virtual filesystem adapters from resolving the same + * configuration differently. + */ +export function assertCanonicalProjectRelativePath( + value: unknown, + label = "Project path", +): string { + const path = assertBoundedPathString(value, label); + + if ( + path.includes("\\") || + path.startsWith("/") || + /^[A-Za-z]:/.test(path) || + /^[A-Za-z][A-Za-z\d+.-]*:\/\//.test(path) || + path.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + throw new TypeError( + `${label} must use canonical forward-slash segments and stay within the project`, + ); + } + + return path; +} + +/** Return whether a value is a canonical, portable project-relative path. */ +export function isCanonicalProjectRelativePath(value: unknown): value is string { + try { + assertCanonicalProjectRelativePath(value); + return true; + } catch { + return false; + } +} + +/** Resolve a validated project-relative path and re-check lexical containment. */ +export function resolveCanonicalProjectRelativePath( + projectDir: string, + value: unknown, + label = "Project path", +): string { + const relativePath = assertCanonicalProjectRelativePath(value, label); + if (typeof projectDir !== "string" || !isAbsolute(projectDir)) { + throw new TypeError(`${label} project directory must be absolute`); + } + + const projectRoot = resolve(projectDir); + const absolutePath = resolve(projectRoot, relativePath); + const pathFromRoot = relative(projectRoot, absolutePath); + if ( + pathFromRoot === ".." || + pathFromRoot.startsWith("../") || + isAbsolute(pathFromRoot) + ) { + throw new TypeError(`${label} must resolve within the project directory`); + } + + return absolutePath; +} + +/** + * Convert one admitted absolute or canonical relative path to its canonical + * project-relative identity. Absolute prefix collisions and every path outside + * the resolved project root are rejected rather than stripped textually. + */ +export function toCanonicalProjectRelativePath( + projectDir: string, + value: unknown, + label = "Project path", +): string { + const admittedProjectDir = assertBoundedPathString( + projectDir, + `${label} project directory`, + ); + if (!isAbsolute(admittedProjectDir)) { + throw new TypeError(`${label} project directory must be absolute`); + } + const projectRoot = resolve(admittedProjectDir); + const admittedPath = assertBoundedPathString(value, label); + const absolutePath = isAbsolute(admittedPath) + ? resolve(admittedPath) + : resolveCanonicalProjectRelativePath(projectRoot, admittedPath, label); + const pathFromRoot = relative(projectRoot, absolutePath).replaceAll("\\", "/"); + if ( + pathFromRoot === "" || + pathFromRoot === ".." || + pathFromRoot.startsWith("../") || + isAbsolute(pathFromRoot) + ) { + throw new TypeError(`${label} must resolve within the project directory`); + } + return assertCanonicalProjectRelativePath(pathFromRoot, label); +} diff --git a/src/utils/remote-host-policy-limits.ts b/src/utils/remote-host-policy-limits.ts new file mode 100644 index 0000000000..2a8315bd6c --- /dev/null +++ b/src/utils/remote-host-policy-limits.ts @@ -0,0 +1,7 @@ +import { MAX_URL_LENGTH_FOR_VALIDATION } from "./constants/limits.ts"; + +/** Maximum number of origins admitted by one remote-import policy. */ +export const MAX_REMOTE_HOST_COUNT = 128; + +/** Maximum length of one configured remote-import URL. */ +export const MAX_REMOTE_HOST_URL_LENGTH = MAX_URL_LENGTH_FOR_VALIDATION; diff --git a/tests/e2e/regressions/rsc-proxy-hydration.test.ts b/tests/e2e/regressions/rsc-proxy-hydration.test.ts index 8005bc7cbf..d63906f5d2 100644 --- a/tests/e2e/regressions/rsc-proxy-hydration.test.ts +++ b/tests/e2e/regressions/rsc-proxy-hydration.test.ts @@ -13,6 +13,9 @@ import { } from "../../_helpers/playwright.ts"; import { cleanupBundler } from "../../../src/rendering/cleanup.ts"; import { startProductionServer } from "../../../src/server/production-server.ts"; +import { bootstrapProd } from "../../../src/server/bootstrap.ts"; +import { runtime } from "#veryfront/platform/adapters/detect.ts"; +import { validateVeryfrontConfig } from "#veryfront/config/schemas/index.ts"; import { base64urlEncode, base64urlEncodeBytes } from "#veryfront/utils/base64url.ts"; const ROOT_LAYOUT_SOURCE = @@ -25,6 +28,7 @@ const LOCAL_RSC_CONFIG_SOURCE = `export default { experimental: { rsc: true } }; const PROXY_MODE_CONFIG_SOURCE = `export default { experimental: { rsc: true }, fs: { + type: "veryfront-api", veryfront: { proxyMode: true, apiBaseUrl: "https://api.veryfront.com" @@ -223,8 +227,31 @@ async function withProxyBrowserPage( Deno.env.set(DISPATCH_PUBLIC_KEY_ENV, trustedPublicKeyPem!); let server: Awaited> | undefined; + let disposeBootstrap: (() => void | Promise) | undefined; try { + await writeTextFile( + join(context.projectDir, "veryfront.config.js"), + LOCAL_RSC_CONFIG_SOURCE, + ); + const adapter = await runtime.get(); + const bootstrap = await bootstrapProd(context.projectDir, adapter); + disposeBootstrap = bootstrap.dispose; + bootstrap.config = validateVeryfrontConfig({ + experimental: { rsc: true }, + fs: { + type: "veryfront-api", + veryfront: { + proxyMode: true, + apiBaseUrl: "https://api.veryfront.com", + }, + }, + }); + await writeTextFile( + join(context.projectDir, "veryfront.config.js"), + PROXY_MODE_CONFIG_SOURCE, + ); + server = await startProductionServer({ projectDir: context.projectDir, port, @@ -232,6 +259,7 @@ async function withProxyBrowserPage( signal: controller.signal, defaultProjectSlug: context.projectId, defaultProjectId: context.projectId, + bootstrapResult: bootstrap, }); await server.ready; await registerTailwindExtension(); @@ -258,6 +286,7 @@ async function withProxyBrowserPage( } finally { controller.abort(); await server?.stop(); + await disposeBootstrap?.(); if (previousDispatchPublicKey === undefined) { Deno.env.delete(DISPATCH_PUBLIC_KEY_ENV); } else { diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index d6d9c626cc..f9c1f360c5 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -59,6 +59,10 @@ const COMPILED_BINARY_E2E_OPTIONS = { timeout: 600_000, }; +// Proxy startup requires an explicit API origin. Requests in these tests are +// rejected by the proxy guard before this deterministic loopback URL is used. +const UNREACHABLE_LOCAL_PROXY_API_BASE_URL = "http://127.0.0.1:1"; + describe("Compiled Binary E2E", COMPILED_BINARY_E2E_OPTIONS, () => { beforeAll(async () => { await ensureBinaryCompiled(); @@ -3062,10 +3066,9 @@ export default function Blog() { ); }); - // Test: Layout rendering with PROXY_MODE=1 (simulates split mode production server) - // Regression test: In split:binary mode, the production server runs with PROXY_MODE=1. - // Without proxy headers, it should fall back to local config and still render layouts. - it("should render layout when PROXY_MODE=1 without proxy headers", async () => { + // Regression test: split-mode production must not fall back to local layout + // files when the proxy omits its project identity and authentication context. + it("should fail closed before loading a local layout when PROXY_MODE=1", async () => { const projectDir = await createTestProject( "proxy-mode-layout-test", ` @@ -3087,51 +3090,30 @@ export default function RootLayout({ children }: { children: React.ReactNode }) `, }, ); + await Deno.writeTextFile(join(projectDir, "veryfront.config.ts"), "export default {};"); await withServer( projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/`); - const html = await response.text(); - - assertEquals(response.status, 200, `Should return 200, got ${response.status}`); - assertStringIncludes( - html, - "proxy-layout-wrapper", - "Should have layout wrapper in proxy mode", - ); - assertStringIncludes( - html, - "Proxy Mode Layout Header", - "Should render layout header in proxy mode", - ); - assertStringIncludes( - html, - "Proxy Mode Layout Footer", - "Should render layout footer in proxy mode", - ); - assertStringIncludes( - html, - "Proxy mode page content", - "Should render page content in proxy mode", - ); + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: "Missing project context", + detail: "x-project-slug header is required in proxy mode", + }); }, "production", - // Clear API env vars to test pure local filesystem fallback { PROXY_MODE: "1", PRODUCTION_MODE: "1", - VERYFRONT_API_BASE_URL: "", + VERYFRONT_API_BASE_URL: UNREACHABLE_LOCAL_PROXY_API_BASE_URL, VERYFRONT_API_TOKEN: "", }, ); }); - // Test: Config layout with PROXY_MODE=1 and components/layouts/ path - // Regression test: In split:binary mode, the production server gets PROXY_MODE=1 and must resolve - // config-based layout paths through the API adapter. Without proxy headers, it should - // fall back to local filesystem and still render the config layout. - it("should render config layout in PROXY_MODE=1 with components/layouts/ path", async () => { + // Configured component layouts obey the same fail-closed proxy boundary. + it("should fail closed before loading a configured layout when PROXY_MODE=1", async () => { const projectDir = await Deno.makeTempDir({ prefix: "vf-e2e-proxy-config-layout-test-" }); await Deno.writeTextFile( @@ -3150,7 +3132,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) await Deno.writeTextFile( join(projectDir, "veryfront.config.ts"), `export default { - fs: { type: "local" }, layout: "components/layouts/DefaultLayout.tsx" };`, ); @@ -3185,32 +3166,17 @@ export default function Home() { projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/`); - const html = await response.text(); - - assertEquals(response.status, 200, `Should return 200, got ${response.status}`); - assertStringIncludes( - html, - "proxy-config-layout", - "Should have config layout in PROXY_MODE=1", - ); - assertStringIncludes(html, "Proxy Config Nav", "Should render nav from config layout"); - assertStringIncludes( - html, - "Proxy Config Footer", - "Should render footer from config layout", - ); - assertStringIncludes( - html, - "Proxy config layout page", - "Should render page content", - ); + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: "Missing project context", + detail: "x-project-slug header is required in proxy mode", + }); }, "production", - // Clear API env vars to test pure local filesystem fallback { PROXY_MODE: "1", PRODUCTION_MODE: "1", - VERYFRONT_API_BASE_URL: "", + VERYFRONT_API_BASE_URL: UNREACHABLE_LOCAL_PROXY_API_BASE_URL, VERYFRONT_API_TOKEN: "", }, ); diff --git a/tests/integration/core/bootstrap.test.ts b/tests/integration/core/bootstrap.test.ts index 5104127f82..7be5ab68a3 100644 --- a/tests/integration/core/bootstrap.test.ts +++ b/tests/integration/core/bootstrap.test.ts @@ -253,7 +253,7 @@ describe("bootstrap - FSAdapter Initialization", { }); }); - it("should skip FSAdapter when fs.type is undefined", async () => { + it("should reject backend options when fs.type is undefined", async () => { const adapter = await getAdapter(); await withTempProjectDir("undefined_type", async (projectDir) => { @@ -266,13 +266,15 @@ describe("bootstrap - FSAdapter Initialization", { };`, ); - const result = await bootstrap(projectDir, adapter); - - assertEquals(result.usingFSAdapter, false); + await assertRejects( + () => bootstrap(projectDir, adapter), + Error, + "Filesystem options must belong to the selected backend type", + ); }); }); - it("should handle fs config with missing credentials gracefully", async () => { + it("should reject incomplete remote filesystem config", async () => { const adapter = await getAdapter(); await withTempProjectDir("missing_creds", async (projectDir) => { @@ -285,13 +287,15 @@ describe("bootstrap - FSAdapter Initialization", { };`, ); - const result = await bootstrap(projectDir, adapter); - - assertExists(result); + await assertRejects( + () => bootstrap(projectDir, adapter), + Error, + "Filesystem options must belong to the selected backend type", + ); }); }); - it("should handle FSAdapter initialization errors gracefully", async () => { + it("should reject options that do not belong to the selected FSAdapter", async () => { const adapter = await getAdapter(); await withTempProjectDir("fs_error", async (projectDir) => { @@ -301,11 +305,11 @@ describe("bootstrap - FSAdapter Initialization", { createBasicConfig({ fsType: "memory" }), ); - const result = await bootstrap(projectDir, adapter); - - assertExists(result); - assertExists(result.config); - assertEquals(result.usingFSAdapter, false); + await assertRejects( + () => bootstrap(projectDir, adapter), + Error, + "Invalid veryfront.config at fs.veryfront", + ); }); }); @@ -676,7 +680,7 @@ describe("bootstrap - Error Handling", () => { };`, ); - await assertRejects(() => bootstrap(projectDir, adapter), Error, "security.cors.origin"); + await assertRejects(() => bootstrap(projectDir, adapter), Error, "security.cors"); }); }); @@ -760,7 +764,7 @@ describe("bootstrap - Error Handling", () => { export default config;`, ); - await assertRejects(() => bootstrap(projectDir, adapter), Error, "Unknown config keys: self"); + await assertRejects(() => bootstrap(projectDir, adapter), Error, 'Unrecognized key: "self"'); }); }); @@ -780,7 +784,7 @@ describe("bootstrap - Error Handling", () => { await assertRejects( () => bootstrap(projectDir, adapter), Error, - "Unknown config keys: onBuild", + 'Unrecognized key: "onBuild"', ); }); }); diff --git a/tests/integration/core/config-loader-edge-cases.test.ts b/tests/integration/core/config-loader-edge-cases.test.ts index 63f20ed7fd..eb586408f3 100644 --- a/tests/integration/core/config-loader-edge-cases.test.ts +++ b/tests/integration/core/config-loader-edge-cases.test.ts @@ -64,20 +64,28 @@ describe("Config Loader - Edge Cases and Error Handling", () => { await assertRejects( () => getConfig(projectDir, adapter), Error, - "Expected object, received string", + "expected object, received string", ); }); }); it("should reject null config export", async () => { await withConfigTest("export default null;", async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "Unknown config keys"); + await assertRejects( + () => getConfig(projectDir, adapter), + Error, + "expected object, received null", + ); }); }); it("should reject undefined config export", async () => { await withConfigTest("export default undefined;", async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "Unknown config keys"); + await assertRejects( + () => getConfig(projectDir, adapter), + Error, + "expected object, received undefined", + ); }); }); @@ -120,12 +128,12 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "security.cors.origin"); + await assertRejects(() => getConfig(projectDir, adapter), Error, "security.cors"); }, ); }); - it("should reject array as cors.origin", async () => { + it("should accept an origin allowlist", async () => { await withConfigTest( ` export default { @@ -137,7 +145,10 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "security.cors.origin"); + const config = await getConfig(projectDir, adapter); + assertEquals(config.security?.cors, { + origin: ["http://localhost:3000"], + }); }, ); }); @@ -154,7 +165,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "security.cors.origin"); + await assertRejects(() => getConfig(projectDir, adapter), Error, "security.cors"); }, ); }); @@ -221,7 +232,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "Unknown config keys"); + await assertRejects(() => getConfig(projectDir, adapter), Error, "Unrecognized keys"); }, ); }); @@ -235,7 +246,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "Unknown config keys"); + await assertRejects(() => getConfig(projectDir, adapter), Error, "Unrecognized keys"); }, ); }); @@ -473,7 +484,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { await assertRejects( () => getConfig(projectDir, adapter), Error, - "Unknown config keys: self", + 'Unrecognized key: "self"', ); }, ); @@ -491,7 +502,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { }; `, async ({ projectDir, adapter }) => { - await assertRejects(() => getConfig(projectDir, adapter), Error, "Unknown config keys"); + await assertRejects(() => getConfig(projectDir, adapter), Error, "Unrecognized keys"); }, ); }); @@ -573,7 +584,7 @@ describe("Config Loader - Edge Cases and Error Handling", () => { await assertRejects( () => getConfig(projectDir, adapter), Error, - "Unknown config keys: port", + 'Unrecognized key: "port"', ); }, ); diff --git a/tests/integration/core/config-schema.test.ts b/tests/integration/core/config-schema.test.ts index 6a24e8dbd9..345f93f216 100644 --- a/tests/integration/core/config-schema.test.ts +++ b/tests/integration/core/config-schema.test.ts @@ -29,7 +29,7 @@ describe("Config validation", () => { await assertRejects( () => getConfig(context.projectDir, adapter), Error, - "security.cors.origin must be a string", + "Invalid veryfront.config at security.cors", ); clearConfigCache(); @@ -51,7 +51,7 @@ describe("Config validation", () => { await assertRejects( () => getConfig(context.projectDir, adapter), Error, - "Unknown config keys: notARealKey", + 'Unrecognized key: "notARealKey"', ); clearConfigCache(); diff --git a/tests/integration/core/loader.test.ts b/tests/integration/core/loader.test.ts index 7ef4d54f15..ba66f1aa4b 100644 --- a/tests/integration/core/loader.test.ts +++ b/tests/integration/core/loader.test.ts @@ -117,7 +117,7 @@ describe("config/loader", () => { ); clearConfigCache(); - await expectConfigError(context.projectDir, ["security.cors.origin", "must be a string"]); + await expectConfigError(context.projectDir, ["Invalid veryfront.config at security.cors"]); }); }); @@ -136,7 +136,7 @@ describe("config/loader", () => { await assertRejects( () => getConfig(context.projectDir, adapter), Error, - "Unknown config keys: unknownKey, anotherUnknown", + 'Unrecognized keys: "unknownKey", "anotherUnknown"', ); }); }); diff --git a/tests/integration/server/production-server.test.ts b/tests/integration/server/production-server.test.ts index f4ee886cf9..10215b3774 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -507,11 +507,16 @@ describe( headers: { "x-shared-middleware": "applied" }, }); }`; + const readMiddlewareSource = (path: string) => { + if (path === "/app/middleware.ts") return middlewareSource; + throw new Deno.errors.NotFound(path); + }; const projectFs = { exists: (path: string) => Promise.resolve(path === "/app/middleware.ts"), - readFile: () => Promise.resolve(middlewareSource), - readTextFile: () => Promise.resolve(middlewareSource), - readOptionalTextFile: () => Promise.resolve(middlewareSource), + readFile: (path: string) => Promise.resolve(readMiddlewareSource(path)), + readTextFile: (path: string) => Promise.resolve(readMiddlewareSource(path)), + readOptionalTextFile: (path: string) => + Promise.resolve(path === "/app/middleware.ts" ? middlewareSource : undefined), }; const resolvedContexts: Array<{ projectSlug: string;