diff --git a/docs/api-reference/veryfront/errors.md b/docs/api-reference/veryfront/errors.md index 74c369603c..a2ce57c872 100644 --- a/docs/api-reference/veryfront/errors.md +++ b/docs/api-reference/veryfront/errors.md @@ -46,7 +46,7 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `API_CLIENT_ERROR` | API client request/response errors (replaces VeryfrontAPIError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L93) | | `API_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L43) | | `API_ROUTE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/route.ts#L43) | -| `ASSET_OPTIMIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L35) | +| `ASSET_OPTIMIZATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L43) | | `AUTHENTICATION_REQUIRED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L11) | | `BRANCH_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L100) | | `BUILD_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/build-errors.ts#L4) | @@ -59,7 +59,7 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `CIRCULAR_DEPENDENCY` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L19) | | `CLIENT_BOUNDARY_VIOLATION` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L3) | | `CLIENT_ONLY_IN_SERVER` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L19) | -| `COMPILATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L59) | +| `COMPILATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L67) | | `COMPONENT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L19) | | `CONFIG_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/config-errors.ts#L4) | | `CONFIG_INVALID` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L12) | @@ -105,6 +105,7 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `LAYOUT_NOT_FOUND` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L27) | | `LOCKFILE_FORMAT_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L51) | | `LOCKFILE_READ_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/module.ts#L59) | +| `MARKDOWN_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L35) | | `MDX_COMPILE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L27) | | `MIDDLEWARE_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/runtime.ts#L51) | | `MODULE_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/module-errors.ts#L4) | @@ -147,8 +148,8 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `SERVICE_OVERLOADED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L44) | | `SOURCE_DIGEST_MISMATCH` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/deploy.ts#L84) | | `SOURCE_MAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/dev.ts#L35) | -| `SOURCEMAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L51) | -| `SSG_GENERATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L43) | +| `SOURCEMAP_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L59) | +| `SSG_GENERATION_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L51) | | `SSR_OUTPUT_LIMIT_EXCEEDED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L51) | | `TEMPLATE_NOT_FOUND` | `veryfront init --template ` (and `npm create veryfront -- --template`) was given a name that is not in the starter catalog. The detail carries the list of valid names so a wrong guess is self-correcting. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/config.ts#L104) | | `TIMEOUT_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L52) | diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index c18123ab6e..1413af4f9c 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -785,7 +785,7 @@ import { | Name | Description | Source | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ApplicationErrorContext` | Sanitized context attached when a runtime reports an application error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L5) | -| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L23) | +| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L27) | | `ApplicationErrorReporterInitializationContext` | Runtime context passed to an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L9) | | `ApplicationErrorReporterInitializer` | Application-composition contract for an error-reporting implementation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L20) | | `ApplicationErrorReporterSession` | Reporter and cleanup ownership returned by an application-selected initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L14) | diff --git a/docs/api-reference/veryfront/observability.md b/docs/api-reference/veryfront/observability.md index 3bf86d2395..1f71681116 100644 --- a/docs/api-reference/veryfront/observability.md +++ b/docs/api-reference/veryfront/observability.md @@ -43,13 +43,13 @@ const result = await withSpan("load-data", async () => { | Name | Description | Source | | ---------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `addSpanEvent` | Event emitted for add span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L70) | -| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L224) | +| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L266) | | `createChildSpan` | Create child span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L79) | | `createFileLogSubscriber` | Create file log subscriber. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/file-log-subscriber.ts#L541) | | `createOpenTelemetryServiceTracer` | Create open telemetry service tracer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/service-tracer.ts#L364) | | `endSpan` | End an active tracing span. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L57) | | `extractContext` | Context for extract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L88) | -| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L242) | +| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L294) | | `getActiveContext` | Context for get active. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L98) | | `getErrorCollector` | Return error collector. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/error-collector.ts#L406) | | `getGlobalMetricsAPI` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L667) | @@ -58,7 +58,7 @@ const result = await withSpan("load-data", async () => { | `getMetricsState` | State for get metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L38) | | `getTraceContext` | Context for get trace. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L500) | | `initAutoInstrumentation` | Initialize automatic instrumentation wrappers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/auto-instrument/orchestrator.ts#L15) | -| `initializeApplicationErrorReporter` | Activate an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L149) | +| `initializeApplicationErrorReporter` | Activate an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L153) | | `initializeOTLP` | Initialize OTLP tracing export. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L113) | | `initMetrics` | Initialize metrics collection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L20) | | `initTracing` | Initialize tracing for the current runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L18) | @@ -131,10 +131,10 @@ const result = await withSpan("load-data", async () => { | Name | Description | Source | | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ApplicationErrorContext` | Sanitized context attached when a runtime reports an application error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L5) | -| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L23) | +| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L27) | | `ApplicationErrorReporterInitializationContext` | Runtime context passed to an explicitly selected reporter initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L9) | | `ApplicationErrorReporterInitializer` | Application-composition contract for an error-reporting implementation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L20) | -| `ApplicationErrorReporterLifecycle` | Active application-error reporter ownership. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L28) | +| `ApplicationErrorReporterLifecycle` | Active application-error reporter ownership. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L29) | | `ApplicationErrorReporterSession` | Reporter and cleanup ownership returned by an application-selected initializer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/observability/application-error-reporter.ts#L14) | | `AttributeValue` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L33) | | `AutoInstrumentConfig` | Configuration used by auto instrument. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/auto-instrument/types.ts#L24) | @@ -237,8 +237,8 @@ import { | Name | Description | Source | | ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L224) | -| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L242) | +| `captureApplicationError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L266) | +| `flushApplicationErrors` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-errors.ts#L294) | | `initializeSentry` | Initialize the process-wide Sentry reporter once. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L86) | | `initializeSentryFromEnv` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L70) | | `isSentryEnabled` | Return whether Sentry is explicitly enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L39) | @@ -250,5 +250,5 @@ import { | Name | Description | Source | | -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `ApplicationErrorContext` | Sanitized context attached when a runtime reports an application error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L5) | -| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L23) | +| `ApplicationErrorReporter` | Provider-neutral application error capture and flush interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/application-error-contract.ts#L27) | | `SentryConfig` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/sentry.ts#L18) | diff --git a/docs/guides/errors.md b/docs/guides/errors.md index d39c544209..a79124a621 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -135,6 +135,13 @@ MDX compilation failed. - **HTTP status:** 500 - **What to do:** Check your MDX file syntax +### markdown-compile-error + +Markdown compilation failed. + +- **HTTP status:** 500 +- **What to do:** Check your Markdown file syntax and frontmatter + ### asset-optimization-error Asset optimization failed. diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts index d3015865f6..102b27a19a 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts @@ -16,6 +16,7 @@ import { createRequire } from "node:module"; import type { BuildContext } from "veryfront/extensions/bundler"; import { + __markEsbuildSourceDiagnosticForTests, __recordOwnershipErrorForTests, __resetOwnershipErrorForTests, __resetServiceRecoveryForTests, @@ -72,6 +73,55 @@ function observeEsbuildServices(): { } describe("EsbuildBundler.transform", () => { + it("normalizes only trusted esbuild diagnostic accessors to an own marker", () => { + const marker = Symbol.for("veryfront.bundler.esbuild-source-diagnostic"); + const failure = new Error("Transform failed"); + let errorsGetterReads = 0; + Object.defineProperty(failure, "errors", { + get() { + errorsGetterReads++; + return [{ location: { line: 1, column: 1 } }]; + }, + }); + const defineProperty = Object.defineProperty; + const previousDefineProperty = Object.getOwnPropertyDescriptor(Object, "defineProperty"); + assertExists(previousDefineProperty); + defineProperty(Object, "defineProperty", { + ...previousDefineProperty, + value: () => { + throw new Error("poisoned Object.defineProperty"); + }, + }); + + try { + __markEsbuildSourceDiagnosticForTests(failure); + } finally { + defineProperty(Object, "defineProperty", previousDefineProperty); + } + + assertEquals(errorsGetterReads, 1); + assertEquals(Object.getOwnPropertyDescriptor(failure, marker)?.value, true); + + let locationGetterReads = 0; + const accessorLocation = Object.defineProperty({}, "location", { + get() { + locationGetterReads++; + return { line: 1, column: 1 }; + }, + }); + const untrustedLocation = new Error("Transform failed"); + Object.defineProperty(untrustedLocation, "errors", { + get() { + return [accessorLocation]; + }, + }); + + __markEsbuildSourceDiagnosticForTests(untrustedLocation); + + assertEquals(locationGetterReads, 0); + assertEquals(Object.getOwnPropertyDescriptor(untrustedLocation, marker), undefined); + }); + it("compiles TS to JS", async () => { const bundler = new EsbuildBundler(); try { diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts index b04773a7f1..2d4de464eb 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts @@ -33,6 +33,14 @@ import { toEsbuildPlugin } from "./plugin-adapter.ts"; type EsbuildModule = any; const ESBUILD_STOP_TIMEOUT_MS = 5_000; +const ESBUILD_SOURCE_DIAGNOSTIC = Symbol.for( + "veryfront.bundler.esbuild-source-diagnostic", +); +const ArrayIsArray = Array.isArray; +const ObjectDefineProperty = Object.defineProperty; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; /** * Unexpected service-child deaths tolerated before the adapter gives up. * @@ -96,6 +104,68 @@ const MAX_CAUSE_DETAIL_LENGTH = 200; /** Absolute POSIX and Windows paths, reduced to a basename below. */ const ABSOLUTE_PATH_PATTERN = /(?:[A-Za-z]:)?(?:\/|\\\\)[^\s"']*/g; +function readOwnDataProperty(value: unknown, key: PropertyKey): unknown { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") + ) { + return undefined; + } + try { + const descriptor = ReflectGetOwnPropertyDescriptor(value, key); + if ( + descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true + ) { + return descriptor.value; + } + } catch { + // A hostile proxy cannot provide trusted diagnostic evidence. + } + return undefined; +} + +/** + * Read esbuild's own diagnostic collection at the direct package boundary. + * + * esbuild exposes `errors` through an own accessor. Invoking that accessor is + * safe only here, before the failure crosses into framework classification. + */ +function readTrustedEsbuildErrors(error: unknown): unknown { + if (error === null || (typeof error !== "object" && typeof error !== "function")) { + return undefined; + } + try { + const descriptor = ReflectGetOwnPropertyDescriptor(error, "errors"); + if (descriptor === undefined) return undefined; + if (ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true) { + return descriptor.value; + } + const getter = readOwnDataProperty(descriptor, "get"); + return typeof getter === "function" ? ReflectApply(getter, error, []) : undefined; + } catch { + return undefined; + } +} + +function markEsbuildSourceDiagnostic(error: unknown): void { + const diagnostics = readTrustedEsbuildErrors(error); + if (!ArrayIsArray(diagnostics)) return; + const length = readOwnDataProperty(diagnostics, "length"); + if (typeof length !== "number") return; + for (let index = 0; index < length; index++) { + const diagnostic = readOwnDataProperty(diagnostics, index); + const location = readOwnDataProperty(diagnostic, "location"); + if (typeof location !== "object" || location === null) continue; + try { + ObjectDefineProperty(error, ESBUILD_SOURCE_DIAGNOSTIC, { value: true }); + } catch { + // Non-extensible failures remain unmarked and classify as infrastructure. + } + return; + } +} + /** * Reduce a cause to a single redacted line. * @@ -508,6 +578,11 @@ export function __recordOwnershipErrorForTests(cause?: unknown): Error { return recordOwnershipError(cause); } +/** Exercise trusted esbuild diagnostic normalization without starting a service. */ +export function __markEsbuildSourceDiagnosticForTests(error: unknown): void { + markEsbuildSourceDiagnostic(error); +} + export function isLiveEsbuildServiceProcess( child: Pick, ): boolean { @@ -705,7 +780,12 @@ export class EsbuildBundler implements Bundler { return runBundlerOperation(async () => { const esbuild = await getEsbuild(); const { code, ...rest } = options; - const result = await invokeEsbuild(() => esbuild.transform(code, rest)); + const result = await invokeEsbuild(() => esbuild.transform(code, rest)).catch( + (error: unknown) => { + markEsbuildSourceDiagnostic(error); + throw error; + }, + ); return { code: result.code, map: result.map, diff --git a/extensions/ext-observability-sentry/src/policy.test.ts b/extensions/ext-observability-sentry/src/policy.test.ts index 9c6d9491ac..2fcc3ca155 100644 --- a/extensions/ext-observability-sentry/src/policy.test.ts +++ b/extensions/ext-observability-sentry/src/policy.test.ts @@ -17,6 +17,7 @@ function createSentrySdk(options: { contexts: [] as Array<[string, Record]>, fingerprints: [] as string[][], flushTimeouts: [] as Array, + levels: [] as string[], tags: [] as Array<[string, string]>, }; const scope = { @@ -26,6 +27,9 @@ function createSentrySdk(options: { setFingerprint(fingerprint: string[]) { state.fingerprints.push(fingerprint); }, + setLevel(level: "error" | "warning") { + state.levels.push(level); + }, setTag(key: string, value: string) { state.tags.push([key, value]); }, @@ -155,6 +159,37 @@ it("policy preserves process_role as a native Sentry tag", () => { ); }); +it("policy tags classified errors and applies their downgraded level", () => { + const { sdk, state } = createSentrySdk(); + + const eventId = captureWithSentryPolicy(sdk, "renderer", new Error("page failed to compile"), { + boundary: "ssr.render", + errorClass: "tenant-build", + level: "warning", + }); + + assertEquals(eventId, "event-id"); + assertEquals( + state.tags.some(([key, value]) => key === "veryfront.error_class" && value === "tenant-build"), + true, + ); + assertEquals(state.levels, ["warning"]); +}); + +it("policy leaves the event level alone for unclassified errors", () => { + const { sdk, state } = createSentrySdk(); + + captureWithSentryPolicy(sdk, "renderer", new Error("request failed"), { + boundary: "ssr.render", + }); + + assertEquals(state.levels, []); + assertEquals( + state.tags.some(([key]) => key === "veryfront.error_class"), + false, + ); +}); + it("policy redacts application error attribute keys and credential-shaped values", () => { assertEquals( sanitizeApplicationErrorAttributes({ diff --git a/extensions/ext-observability-sentry/src/policy.ts b/extensions/ext-observability-sentry/src/policy.ts index 06840f9171..0232601aff 100644 --- a/extensions/ext-observability-sentry/src/policy.ts +++ b/extensions/ext-observability-sentry/src/policy.ts @@ -48,6 +48,9 @@ const SENSITIVE_ATTRIBUTE_KEY_PATTERN = export type SentryPolicyScope = { setContext(name: string, context: Record): void; setFingerprint(fingerprint: string[]): void; + // Optional so a third-party adapter written against the previous shape keeps + // compiling; `SentryPolicyScope` is part of this package's published surface. + setLevel?(level: "error" | "warning"): void; setTag(key: string, value: string): void; }; @@ -120,6 +123,8 @@ export function applySentryScopePolicy( scope.setTag("service.name", serviceName); if (context.processRole) scope.setTag("process_role", context.processRole); scope.setTag("veryfront.boundary", context.boundary); + if (context.errorClass) scope.setTag("veryfront.error_class", context.errorClass); + if (context.level) scope.setLevel?.(context.level); if (context.method) scope.setTag("http.request.method", context.method); if (context.requestId) scope.setTag("veryfront.request_id", context.requestId); if (context.traceId) { diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index 7b20ec5202..871ea5dc1b 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -44,7 +44,6 @@ "src/rendering/utils/react-helpers.test.ts", "src/server/build-service-worker.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", "src/transforms/mdx/esm-module-loader/loader.test.ts", "src/workflow/api/workflow-client.test.ts" diff --git a/src/errors/catalog/build-errors.test.ts b/src/errors/catalog/build-errors.test.ts index e3bdfedaf0..9e7f5e2fbb 100644 --- a/src/errors/catalog/build-errors.test.ts +++ b/src/errors/catalog/build-errors.test.ts @@ -11,6 +11,7 @@ describe("errors/catalog/build-errors", () => { "bundle-error", "typescript-error", "mdx-compile-error", + "markdown-compile-error", "asset-optimization-error", "ssg-generation-error", "sourcemap-error", @@ -37,8 +38,8 @@ describe("errors/catalog/build-errors", () => { } }); - it("should have 8 entries", () => { - assertEquals(Object.keys(BUILD_ERROR_CATALOG).length, 8); + it("should have 9 entries", () => { + assertEquals(Object.keys(BUILD_ERROR_CATALOG).length, 9); }); it("build-failed should have tips", () => { diff --git a/src/errors/catalog/build-errors.ts b/src/errors/catalog/build-errors.ts index 2e9d6a17c6..b841839063 100644 --- a/src/errors/catalog/build-errors.ts +++ b/src/errors/catalog/build-errors.ts @@ -56,6 +56,21 @@ import Button from './components/Button.jsx' `, }), + "markdown-compile-error": createErrorSolution("markdown-compile-error", { + title: "Markdown compilation failed", + message: "Failed to compile Markdown file.", + steps: [ + "Check for syntax errors in your Markdown file", + "Ensure frontmatter YAML is valid", + "Check for unclosed frontmatter blocks", + ], + example: `--- +title: My Post +--- + +# Hello World`, + }), + "asset-optimization-error": createSimpleError( "asset-optimization-error", "Asset optimization failed", diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index 8f2a2f63fc..d18adf8119 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -29,9 +29,9 @@ describe("error-registry", () => { assertEquals(slugs.length, uniqueSlugs.size, "Duplicate slugs detected"); }); - it("should have 109 registered errors", () => { + it("should have 110 registered errors", () => { const slugs = getAllSlugs(); - assertEquals(slugs.length, 109); + assertEquals(slugs.length, 110); }); }); @@ -176,7 +176,7 @@ describe("error-registry", () => { it("should return BUILD errors", () => { const errors = getErrorsByCategory("BUILD"); - assertEquals(errors.length, 8); + assertEquals(errors.length, 9); for (const error of errors) { assertEquals(error.category, "BUILD"); } @@ -318,7 +318,7 @@ describe("error-registry", () => { describe("error categories coverage", () => { const expectedCategoryCounts: Record = { CONFIG: 12, - BUILD: 8, + BUILD: 9, RUNTIME: 10, ROUTE: 6, MODULE: 8, diff --git a/src/errors/error-registry/build.ts b/src/errors/error-registry/build.ts index fef330d262..c41a68e747 100644 --- a/src/errors/error-registry/build.ts +++ b/src/errors/error-registry/build.ts @@ -32,6 +32,14 @@ export const MDX_COMPILE_ERROR = defineError({ suggestion: "Check your MDX file syntax", }); +export const MARKDOWN_COMPILE_ERROR = defineError({ + slug: "markdown-compile-error", + category: "BUILD", + status: 500, + title: "Markdown compilation failed", + suggestion: "Check your Markdown file syntax and frontmatter", +}); + export const ASSET_OPTIMIZATION_ERROR = defineError({ slug: "asset-optimization-error", category: "BUILD", @@ -70,6 +78,7 @@ export const BUILD_REGISTRY = { "bundle-error": BUNDLE_ERROR, "typescript-error": TYPESCRIPT_ERROR, "mdx-compile-error": MDX_COMPILE_ERROR, + "markdown-compile-error": MARKDOWN_COMPILE_ERROR, "asset-optimization-error": ASSET_OPTIMIZATION_ERROR, "ssg-generation-error": SSG_GENERATION_ERROR, "sourcemap-error": SOURCEMAP_ERROR, diff --git a/src/errors/index.ts b/src/errors/index.ts index 1088ab5238..48970736d8 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -100,6 +100,7 @@ export { LAYOUT_NOT_FOUND, LOCKFILE_FORMAT_MISMATCH, LOCKFILE_READ_ERROR, + MARKDOWN_COMPILE_ERROR, MDX_COMPILE_ERROR, MIDDLEWARE_ERROR, // MODULE diff --git a/src/errors/tenant-classification.test.ts b/src/errors/tenant-classification.test.ts new file mode 100644 index 0000000000..485d91831b --- /dev/null +++ b/src/errors/tenant-classification.test.ts @@ -0,0 +1,86 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { BUILD_FAILED, COMPILATION_ERROR, MDX_COMPILE_ERROR } from "./error-registry/build.ts"; +import { isTenantSourceBuildError } from "./tenant-classification.ts"; + +describe("errors/tenant-classification", () => { + it("uses the Set membership intrinsic captured during module initialization", () => { + const previous = Object.getOwnPropertyDescriptor(Set.prototype, "has"); + if (!previous || typeof previous.value !== "function") { + throw new Error("Expected Set.prototype.has descriptor"); + } + Object.defineProperty(Set.prototype, "has", { + ...previous, + value: () => true, + }); + + try { + assertEquals(isTenantSourceBuildError(BUILD_FAILED.create()), false); + assertEquals(isTenantSourceBuildError(MDX_COMPILE_ERROR.create()), true); + } finally { + Object.defineProperty(Set.prototype, "has", previous); + } + }); + + it("requires an own data context marker without invoking accessors", () => { + const inheritedContext = Object.create({ tenantBuildFailure: true }); + assertEquals( + isTenantSourceBuildError(COMPILATION_ERROR.create({ context: inheritedContext })), + false, + ); + + let getterRead = false; + const accessorContext = Object.defineProperty({}, "tenantBuildFailure", { + configurable: true, + get() { + getterRead = true; + return true; + }, + }); + const previousDescriptorValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: true, + }); + try { + assertEquals( + isTenantSourceBuildError(COMPILATION_ERROR.create({ context: accessorContext })), + false, + ); + assertEquals(getterRead, false); + } finally { + if (previousDescriptorValue) { + Object.defineProperty(Object.prototype, "value", previousDescriptorValue); + } else { + delete (Object.prototype as { value?: unknown }).value; + } + } + }); + + it("ignores Object prototype pollution while preserving explicit context markers", () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, "tenantBuildFailure"); + Object.defineProperty(Object.prototype, "tenantBuildFailure", { + configurable: true, + value: true, + }); + + try { + assertEquals( + isTenantSourceBuildError(COMPILATION_ERROR.create({ context: {} })), + false, + ); + assertEquals( + isTenantSourceBuildError( + COMPILATION_ERROR.create({ context: { tenantBuildFailure: true } }), + ), + true, + ); + } finally { + if (previous) { + Object.defineProperty(Object.prototype, "tenantBuildFailure", previous); + } else { + delete (Object.prototype as { tenantBuildFailure?: unknown }).tenantBuildFailure; + } + } + }); +}); diff --git a/src/errors/tenant-classification.ts b/src/errors/tenant-classification.ts new file mode 100644 index 0000000000..7742a07b9e --- /dev/null +++ b/src/errors/tenant-classification.ts @@ -0,0 +1,66 @@ +/** + * Single owner for the "is this build failure the tenant's fault?" question. + * + * Two layers need the answer and cannot import each other: the module loader + * (which tags errors at their capture seam) and observability (which must not + * depend on the rendering layer). They exchange the verdict through a shared + * symbol, but the verdict itself is computed here so a new tenant-facing slug + * only has to be added once. Duplicating the slug set drifts silently — the + * same error would classify differently depending on which seam saw it first. + */ + +import { snapshotVeryfrontError } from "./types.ts"; + +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; +const SetPrototypeHas = Set.prototype.has; + +/** + * BUILD registry slugs that describe tenant source failing to compile, as + * opposed to framework cache/bundle/asset infrastructure failing in the same + * phase. + */ +const TENANT_BUILD_ERROR_SLUGS = new Set([ + "typescript-error", + "mdx-compile-error", + "markdown-compile-error", +]); + +function hasOwnTrueDataProperty(value: object, key: PropertyKey): boolean { + try { + const descriptor = ReflectGetOwnPropertyDescriptor(value, key); + return descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true && + descriptor.value === true; + } catch { + return false; + } +} + +/** + * Whether `error` describes tenant source or content failing to build (a page + * that does not compile, MDX that does not parse) rather than a framework + * fault. + * + * Recognizes two discriminators, both written at the seam that knows: + * - an explicit `tenantBuildFailure: true` error context, set by a compiler + * stage that inspected the diagnostic, and + * - a tenant-facing BUILD registry slug. + * + * The module loader's symbol tag is deliberately *not* read here: it is set + * from this predicate, so reading it back would be circular. + */ +export function isTenantSourceBuildError(error: unknown): boolean { + const snapshot = snapshotVeryfrontError(error); + if (!snapshot) return false; + const errorContext = snapshot.context; + if ( + typeof errorContext === "object" && errorContext !== null && + hasOwnTrueDataProperty(errorContext, "tenantBuildFailure") + ) { + return true; + } + return snapshot.category === "BUILD" && + ReflectApply(SetPrototypeHas, TENANT_BUILD_ERROR_SLUGS, [snapshot.slug]) === true; +} diff --git a/src/observability/README.md b/src/observability/README.md index a86b0ba6c0..0a12ac76b0 100644 --- a/src/observability/README.md +++ b/src/observability/README.md @@ -216,6 +216,13 @@ context values do not replace application control flow. for timeout, rejection, exceptions, or an invalid timeout; it never waits for a non-cooperative reporter after the deadline. +Reporter context is sanitized before capture. Public fields include +`boundary`, `method`, `processRole`, `requestId`, `spanId`, `traceId`, +`errorClass`, `level`, and scalar `attributes`. Tenant-authored build and +content compile failures are still captured, but Veryfront tags them with +`errorClass: "tenant-build"` and downgrades the default `level` to `"warning"`. +Sentry integrations consume that class as the `veryfront.error_class` tag. + Concrete reporters are separate extension packages. Sentry configuration and runtime setup are documented by `@veryfront/ext-observability-sentry`. diff --git a/src/observability/application-error-contract.ts b/src/observability/application-error-contract.ts index 971da7d7d9..f171c61dd0 100644 --- a/src/observability/application-error-contract.ts +++ b/src/observability/application-error-contract.ts @@ -15,6 +15,10 @@ export type ApplicationErrorContext = { spanId?: string; /** OpenTelemetry trace correlation identifier. */ traceId?: string; + /** Stable failure classification (e.g. "tenant-build") tagged on the event. */ + errorClass?: string; + /** Severity of the captured event; reporters default to "error" when unset. */ + level?: "error" | "warning"; /** Sanitized scalar metadata for the failure boundary. */ attributes?: Record; }; diff --git a/src/observability/application-errors.test.ts b/src/observability/application-errors.test.ts index ba7e18cf21..7bf1a720ec 100644 --- a/src/observability/application-errors.test.ts +++ b/src/observability/application-errors.test.ts @@ -13,7 +13,23 @@ import { setApplicationErrorReporter, } from "./application-errors.ts"; import type { ApplicationErrorContext as SharedApplicationErrorContext } from "./application-error-contract.ts"; -import { CONFIG_PARSE_ERROR, INITIALIZATION_ERROR } from "#veryfront/errors"; +import { + ASSET_OPTIMIZATION_ERROR, + BUILD_FAILED, + BUNDLE_ERROR, + COMPILATION_ERROR, + CONFIG_PARSE_ERROR, + createError, + IMPORT_RESOLUTION_ERROR, + INITIALIZATION_ERROR, + MARKDOWN_COMPILE_ERROR, + MDX_COMPILE_ERROR, + RENDER_ERROR, + SOURCEMAP_ERROR, + SSG_GENERATION_ERROR, + toError, + TYPESCRIPT_ERROR, +} from "#veryfront/errors"; it("application error reporter is optional", async () => { setApplicationErrorReporter(undefined); @@ -131,6 +147,317 @@ it("application error reporter ignores client-class veryfront errors", () => { assertEquals(captures, [serverError, plainError]); }); +it("application error reporter downgrades tenant build errors to tagged warnings", () => { + const captures: Array<{ error: unknown; context: SharedApplicationErrorContext }> = []; + setApplicationErrorReporter({ + capture(error, context) { + captures.push({ error, context }); + return "event-id"; + }, + flush: () => Promise.resolve(true), + }); + + const compileError = TYPESCRIPT_ERROR.create({ + detail: "TypeScript compilation failed in /pages/index.tsx", + }); + const legacyBuildError = toError( + createError({ type: "build", message: "Module transform cache write failed" }), + ); + const pipelineError = RENDER_ERROR.create({ + detail: "Critical page module(s) failed to load:\n/pages/index.mdx: bad syntax", + context: { buildFailure: true, tenantBuildFailure: true }, + }); + const frameworkPipelineError = RENDER_ERROR.create({ + detail: "Critical page module(s) failed to load while persisting its cache entry", + context: { buildFailure: true, tenantBuildFailure: false }, + }); + const mdxRegistryError = MDX_COMPILE_ERROR.create({ + detail: "MDX compilation failed in /pages/index.mdx", + }); + const markdownRegistryError = MARKDOWN_COMPILE_ERROR.create({ + detail: "Markdown frontmatter failed in /pages/index.md", + }); + const sourceCompilationError = COMPILATION_ERROR.create({ + detail: "TypeScript syntax failed in /pages/index.tsx", + context: { tenantBuildFailure: true }, + }); + const frameworkImportError = IMPORT_RESOLUTION_ERROR.create({ + detail: "Could not resolve framework import: #veryfront/missing", + }); + const frameworkError = INITIALIZATION_ERROR.create({ + detail: "renderer failed to initialize", + }); + const assetOptimizationError = ASSET_OPTIMIZATION_ERROR.create({ + detail: "framework image optimization failed", + }); + const sourcemapError = SOURCEMAP_ERROR.create({ + detail: "framework source map generation failed", + }); + const frameworkCacheWriteError = BUILD_FAILED.create({ + detail: "Failed to write MDX module cache file: ", + }); + const frameworkBundleError = BUNDLE_ERROR.create({ + detail: "Failed to regenerate framework bundle cache entry: ", + }); + const ssgInfrastructureError = SSG_GENERATION_ERROR.create({ + detail: "Failed to write generated page output", + cause: Object.assign(new Error("No space left on device"), { code: "ENOSPC" }), + context: { route: "/" }, + }); + + assertEquals( + captureApplicationError(compileError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(pipelineError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(mdxRegistryError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(markdownRegistryError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(sourceCompilationError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(frameworkImportError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(frameworkError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(assetOptimizationError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(sourcemapError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(frameworkCacheWriteError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(frameworkBundleError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(frameworkPipelineError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(legacyBuildError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals( + captureApplicationError(ssgInfrastructureError, { boundary: "ssr.render" }), + "event-id", + ); + + const genericCompilationError = COMPILATION_ERROR.create({ + detail: "esbuild service exited unexpectedly", + }); + assertEquals( + captureApplicationError(genericCompilationError, { boundary: "ssr.render" }), + "event-id", + ); + + assertEquals(captures.length, 15); + // Tenant build/content failures stay visible for escalation analysis, but + // are tagged and downgraded so they stop surfacing as error-level issues. + assertEquals(captures[0]?.context.errorClass, "tenant-build"); + assertEquals(captures[0]?.context.level, "warning"); + assertEquals(captures[1]?.context.errorClass, "tenant-build"); + assertEquals(captures[1]?.context.level, "warning"); + assertEquals(captures[2]?.context.errorClass, "tenant-build"); + assertEquals(captures[2]?.context.level, "warning"); + assertEquals(captures[3]?.context.errorClass, "tenant-build"); + assertEquals(captures[3]?.context.level, "warning"); + assertEquals(captures[4]?.context.errorClass, "tenant-build"); + assertEquals(captures[4]?.context.level, "warning"); + // Generic import-resolution and other framework failures keep their default + // error-level capture. Source-aware resolver seams add tenant context when + // project code is actually responsible. + assertEquals(captures[5]?.context.errorClass, undefined); + assertEquals(captures[5]?.context.level, undefined); + assertEquals(captures[6]?.context.errorClass, undefined); + assertEquals(captures[6]?.context.level, undefined); + assertEquals(captures[7]?.context.errorClass, undefined); + assertEquals(captures[7]?.context.level, undefined); + assertEquals(captures[8]?.context.errorClass, undefined); + assertEquals(captures[8]?.context.level, undefined); + assertEquals(captures[9]?.context.errorClass, undefined); + assertEquals(captures[9]?.context.level, undefined); + assertEquals(captures[10]?.context.errorClass, undefined); + assertEquals(captures[10]?.context.level, undefined); + assertEquals(captures[11]?.context.errorClass, undefined); + assertEquals(captures[11]?.context.level, undefined); + assertEquals(captures[12]?.context.errorClass, undefined); + assertEquals(captures[12]?.context.level, undefined); + assertEquals(captures[13]?.context.errorClass, undefined); + assertEquals(captures[13]?.context.level, undefined); + assertEquals(captures[14]?.context.errorClass, undefined); + assertEquals(captures[14]?.context.level, undefined); +}); + +it("application error reporter ignores inherited tenant build tags", () => { + const tenantBuildFailureTag = Symbol.for("veryfront.module-loader.tenant-build-failure"); + const previousDescriptor = Object.getOwnPropertyDescriptor( + Error.prototype, + tenantBuildFailureTag, + ); + Object.defineProperty(Error.prototype, tenantBuildFailureTag, { + configurable: true, + value: true, + }); + + try { + const captures: Array<{ error: unknown; context: SharedApplicationErrorContext }> = []; + setApplicationErrorReporter({ + capture(error, context) { + captures.push({ error, context }); + return "event-id"; + }, + flush: () => Promise.resolve(true), + }); + + assertEquals( + captureApplicationError(new Error("framework failed"), { boundary: "ssr.render" }), + "event-id", + ); + assertEquals(captures[0]?.context.errorClass, undefined); + assertEquals(captures[0]?.context.level, undefined); + + const accessorTagError = new Error("framework failed"); + let getterRead = false; + Object.defineProperty(accessorTagError, tenantBuildFailureTag, { + configurable: true, + get() { + getterRead = true; + return true; + }, + }); + + assertEquals( + captureApplicationError(accessorTagError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals(getterRead, false); + assertEquals(captures[1]?.context.errorClass, undefined); + assertEquals(captures[1]?.context.level, undefined); + } finally { + if (previousDescriptor) { + Object.defineProperty(Error.prototype, tenantBuildFailureTag, previousDescriptor); + } else { + delete (Error.prototype as { [tenantBuildFailureTag]?: unknown })[tenantBuildFailureTag]; + } + } +}); + +it("application error reporter rejects prototype-polluted accessor tag descriptors", () => { + const tenantBuildFailureTag = Symbol.for("veryfront.module-loader.tenant-build-failure"); + const previousDescriptorValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + const previousHasOwnProperty = Object.getOwnPropertyDescriptor( + Object.prototype, + "hasOwnProperty", + ); + if (!previousHasOwnProperty) throw new Error("Expected Object.prototype.hasOwnProperty"); + const frameworkError = new Error("framework failed"); + Object.defineProperty(frameworkError, tenantBuildFailureTag, { + configurable: true, + get: undefined, + set: undefined, + }); + const tenantError = new Error("tenant failed"); + Object.defineProperty(tenantError, tenantBuildFailureTag, { + configurable: true, + value: true, + }); + Object.defineProperty(Object.prototype, "hasOwnProperty", { + ...previousHasOwnProperty, + value: () => true, + }); + Object.defineProperty(Object.prototype, "value", { configurable: true, value: true }); + + try { + const captures: Array<{ error: unknown; context: SharedApplicationErrorContext }> = []; + setApplicationErrorReporter({ + capture(error, context) { + captures.push({ error, context }); + return "event-id"; + }, + flush: () => Promise.resolve(true), + }); + + assertEquals( + captureApplicationError(frameworkError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals(captures[0]?.context.errorClass, undefined); + assertEquals(captures[0]?.context.level, undefined); + assertEquals( + captureApplicationError(tenantError, { boundary: "ssr.render" }), + "event-id", + ); + assertEquals(captures[1]?.context.errorClass, "tenant-build"); + assertEquals(captures[1]?.context.level, "warning"); + } finally { + if (previousDescriptorValue) { + Object.defineProperty(Object.prototype, "value", previousDescriptorValue); + } else { + delete (Object.prototype as { value?: unknown }).value; + } + Object.defineProperty(Object.prototype, "hasOwnProperty", previousHasOwnProperty); + } +}); + +it("application error reporter ignores poisoned Reflect descriptor lookups for tenant tags", () => { + const previousDescriptor = Object.getOwnPropertyDescriptor( + Reflect, + "getOwnPropertyDescriptor", + ); + if (!previousDescriptor || typeof previousDescriptor.value !== "function") { + throw new Error("Expected Reflect.getOwnPropertyDescriptor descriptor"); + } + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", { + ...previousDescriptor, + value: () => ({ + configurable: true, + enumerable: false, + value: true, + writable: false, + }), + }); + + try { + const captures: Array<{ error: unknown; context: SharedApplicationErrorContext }> = []; + setApplicationErrorReporter({ + capture(error, context) { + captures.push({ error, context }); + return "event-id"; + }, + flush: () => Promise.resolve(true), + }); + + assertEquals( + captureApplicationError(new Error("framework failed"), { boundary: "ssr.render" }), + "event-id", + ); + assertEquals(captures[0]?.context.errorClass, undefined); + assertEquals(captures[0]?.context.level, undefined); + } finally { + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", previousDescriptor); + } +}); + it("application error capture failures never replace application control flow", () => { const hostile = new Proxy({}, { getPrototypeOf() { diff --git a/src/observability/application-errors.ts b/src/observability/application-errors.ts index d6be4129c0..f08bc9d907 100644 --- a/src/observability/application-errors.ts +++ b/src/observability/application-errors.ts @@ -1,4 +1,5 @@ import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; +import { isTenantSourceBuildError } from "#veryfront/errors/tenant-classification.ts"; import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { sanitizeTelemetryAttributes, sanitizeTelemetryText } from "./telemetry-error.ts"; import { MAX_APPLICATION_ERROR_CONTEXT_VALUE_LENGTH } from "./limits.ts"; @@ -33,6 +34,9 @@ export type ApplicationErrorReporterLifecycle = { }; const MAX_APPLICATION_ERROR_SERVICE_NAME_LENGTH = 255; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; let reporter: ApplicationErrorReporter | undefined; let reporterOwner: symbol | undefined; @@ -221,6 +225,44 @@ export function initializeApplicationErrorReporter(options: { }); } +const TENANT_BUILD_ERROR_CLASS = "tenant-build"; + +/** + * Tag applied by the module loader only after an explicit tenant-source + * classification. + * + * The tag is read through the shared symbol registry instead of importing the + * rendering layer; see src/rendering/orchestrator/module-loader/build-failure.ts. + */ +const TENANT_BUILD_FAILURE_TAG = Symbol.for("veryfront.module-loader.tenant-build-failure"); + +function hasOwnTrueSymbol(value: object, key: symbol): boolean { + const descriptor = ReflectGetOwnPropertyDescriptor(value, key); + return descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true && + descriptor.value === true; +} + +/** + * Whether `error` describes tenant build/content failing to compile (a page + * that does not build, MDX that does not parse) rather than a framework fault. + * + * Recognizes the existing discriminators at their capture seam: + * - the module loader's tenant-build-failure tag, and + * - the shared slug/context classification in `#veryfront/errors`, which is the + * single owner of the tenant-source verdict. + */ +function isTenantBuildError(error: unknown): boolean { + try { + if (error instanceof Error) { + if (hasOwnTrueSymbol(error, TENANT_BUILD_FAILURE_TAG)) return true; + } + return isTenantSourceBuildError(error); + } catch { + return false; + } +} + export function captureApplicationError( error: unknown, context: ApplicationErrorContext, @@ -230,7 +272,17 @@ export function captureApplicationError( if (!currentReporter) return undefined; try { - const snapshot = snapshotApplicationErrorContext(context); + // Tenant build/content failures stay captured for escalation analysis, + // but are tagged and downgraded so per-request tenant mistakes stop + // surfacing as error-level framework issues. + const classifiedContext = isTenantBuildError(error) + ? { + ...context, + errorClass: context.errorClass ?? TENANT_BUILD_ERROR_CLASS, + level: context.level ?? "warning" as const, + } + : context; + const snapshot = snapshotApplicationErrorContext(classifiedContext); return snapshot ? currentReporter.capture(error, snapshot) : undefined; } catch { // Error reporting is diagnostic and must never replace the application @@ -286,12 +338,17 @@ function snapshotApplicationErrorContext( if (!boundary) return null; const snapshot: ApplicationErrorContext = { boundary }; - for (const key of ["method", "processRole", "requestId", "spanId", "traceId"] as const) { + for ( + const key of ["method", "processRole", "requestId", "spanId", "traceId", "errorClass"] as const + ) { const value = context[key]; if (value === undefined) continue; const normalized = normalizeContextValue(value); if (normalized) snapshot[key] = normalized; } + if (context.level === "error" || context.level === "warning") { + snapshot.level = context.level; + } const attributes = sanitizeTelemetryAttributes(context.attributes); if (attributes && Object.keys(attributes).length > 0) { snapshot.attributes = Object.freeze(attributes); diff --git a/src/rendering/orchestrator/module-loader/build-failure.ts b/src/rendering/orchestrator/module-loader/build-failure.ts index ec504279ab..60f467095d 100644 --- a/src/rendering/orchestrator/module-loader/build-failure.ts +++ b/src/rendering/orchestrator/module-loader/build-failure.ts @@ -14,17 +14,67 @@ * error at the point of failure instead of leaving later layers to infer it. */ +import { isTenantSourceBuildError } from "#veryfront/errors/tenant-classification.ts"; + +const ObjectDefineProperty = Object.defineProperty; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; + const BUILD_FAILURE = Symbol.for("veryfront.module-loader.build-failure"); +const TENANT_BUILD_FAILURE = Symbol.for("veryfront.module-loader.tenant-build-failure"); + +/** + * Modules are strict mode, so a plain assignment onto a frozen error throws. + * These taggers run inside `catch` blocks, where a throw would replace the + * original error with a `TypeError` and lose the failure entirely. + */ +function defineTag(error: Error, tag: symbol): void { + try { + ObjectDefineProperty(error, tag, { value: true, configurable: true }); + } catch { + // Sealed or non-configurable: the error stays untagged, which degrades to + // the pre-classification behavior rather than destroying the error. + } +} -type TaggedError = Error & { [BUILD_FAILURE]?: true }; +function hasOwnTrueTag(error: Error, tag: symbol): boolean { + const descriptor = ReflectGetOwnPropertyDescriptor(error, tag); + return descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true && + descriptor.value === true; +} /** Tag `error` as a build failure and return it. */ export function markBuildFailure(error: unknown): unknown { - if (error instanceof Error) (error as TaggedError)[BUILD_FAILURE] = true; + if (error instanceof Error) { + defineTag(error, BUILD_FAILURE); + if (isTenantSourceBuildError(error)) defineTag(error, TENANT_BUILD_FAILURE); + } + return error; +} + +/** + * Tag `error` as a build failure the tenant's own source caused, and return it. + * + * For seams that know the provenance from control flow rather than from a + * registry slug — an import specifier that still does not resolve after a full + * rebuild, for instance, is a path the project authored. + */ +export function markTenantBuildFailure(error: unknown): unknown { + if (error instanceof Error) { + defineTag(error, BUILD_FAILURE); + defineTag(error, TENANT_BUILD_FAILURE); + } return error; } /** True when `error` was raised while compiling or resolving project source. */ export function isBuildFailure(error: unknown): boolean { - return error instanceof Error && (error as TaggedError)[BUILD_FAILURE] === true; + return error instanceof Error && hasOwnTrueTag(error, BUILD_FAILURE); +} + +/** True only for a build failure explicitly classified as tenant source. */ +export function isTenantBuildFailure(error: unknown): boolean { + return error instanceof Error && hasOwnTrueTag(error, TENANT_BUILD_FAILURE); } diff --git a/src/rendering/orchestrator/module-loader/dependency-resolver.test.ts b/src/rendering/orchestrator/module-loader/dependency-resolver.test.ts index 0e4a2013bc..718da7363b 100644 --- a/src/rendering/orchestrator/module-loader/dependency-resolver.test.ts +++ b/src/rendering/orchestrator/module-loader/dependency-resolver.test.ts @@ -102,6 +102,120 @@ describe("module-loader/dependency-resolver", () => { ); }); + it("fails closed when side-effect import collection exceeds its bound", async () => { + const adapter = await getLocalAdapter(); + const fileContent = Array.from( + { length: 501 }, + (_, index) => `import "./value-${index}";`, + ).join("\n"); + + await assertRejects( + () => + resolveModuleDependencies({ + adapter, + fileContent, + filePath: "/project/page.tsx", + projectDir: "/project", + }), + RangeError, + "more than 500 side-effect relative imports", + ); + }); + + it("resolves and rewrites side-effect alias and relative imports", async () => { + await withDependencyFixture( + { + "app/page.tsx": [ + `import "@/setup";`, + `import "./local-setup";`, + `export default function Page() { return null; }`, + ].join("\n"), + "components/setup.ts": `globalThis.aliasReady = true;`, + "app/local-setup.ts": `globalThis.localReady = true;`, + }, + async ({ projectDir }) => { + const adapter = await getLocalAdapter(); + const filePath = join(projectDir, "app/page.tsx"); + const fileContent = await Deno.readTextFile(filePath); + + const deps = await resolveModuleDependencies({ + adapter, + fileContent, + filePath, + projectDir, + }); + + assertEquals(deps.length, 2); + const rewritten = rewriteResolvedDependencyImports( + fileContent, + deps.map((dep, index) => ({ ...dep, depTempPath: `/tmp/setup-${index}.js` })), + ); + assertStringIncludes(rewritten, `import "file:///tmp/setup-0.js";`); + assertStringIncludes(rewritten, `import "file:///tmp/setup-1.js";`); + }, + ); + }); + + it("resolves side-effect imports after statements and keyword comments", async () => { + await withDependencyFixture( + { + "app/page.tsx": [ + `const ready = true; import /* preload */ "@/setup";`, + `import /* preload */ "./local-setup";`, + `export default function Page() { return ready; }`, + ].join("\n"), + "components/setup.ts": `globalThis.aliasReady = true;`, + "app/local-setup.ts": `globalThis.localReady = true;`, + }, + async ({ projectDir }) => { + const adapter = await getLocalAdapter(); + const filePath = join(projectDir, "app/page.tsx"); + const fileContent = await Deno.readTextFile(filePath); + + const deps = await resolveModuleDependencies({ + adapter, + fileContent, + filePath, + projectDir, + }); + + assertEquals(deps.map((dependency) => dependency.relativePath), [ + "setup", + "./local-setup", + ]); + }, + ); + }); + + it("does not resolve import-looking JSX display text", async () => { + await withDependencyFixture( + { + "app/page.tsx": [ + `const label = "Example: ";`, + `export default function Page() {`, + ` return {label}import "./example";`, + `}`, + ].join("\n"), + "app/example.ts": `export const example = true;`, + }, + async ({ projectDir }) => { + const adapter = await getLocalAdapter(); + const filePath = join(projectDir, "app/page.tsx"); + const fileContent = await Deno.readTextFile(filePath); + + const deps = await resolveModuleDependencies({ + adapter, + fileContent, + filePath, + projectDir, + }); + + assertEquals(deps, []); + assertEquals(rewriteResolvedDependencyImports(fileContent, []), fileContent); + }, + ); + }); + it("resolves alias and relative imports while ignoring already transformed file imports", async () => { await withDependencyFixture( { diff --git a/src/rendering/orchestrator/module-loader/dependency-resolver.ts b/src/rendering/orchestrator/module-loader/dependency-resolver.ts index 4aeee4ec20..6cbc3bdf55 100644 --- a/src/rendering/orchestrator/module-loader/dependency-resolver.ts +++ b/src/rendering/orchestrator/module-loader/dependency-resolver.ts @@ -4,6 +4,7 @@ import { parallelMap, rendererLogger } from "#veryfront/utils"; import { findDynamicImportSpans, findStaticImportFromSpans, + findStaticSideEffectImportSpans, replaceSourceSpans, type SourceSpanReplacement, type StaticImportSpan, @@ -40,6 +41,7 @@ type AliasImport = { start: number; end: number; isDynamic: boolean; + isSideEffect: boolean; }; type RelativeImport = { full: string; @@ -48,6 +50,7 @@ type RelativeImport = { start: number; end: number; isDynamic: boolean; + isSideEffect: boolean; }; /** Resolved local module dependency discovered in a source module. */ @@ -61,6 +64,8 @@ export type ResolvedModuleDependency = { isLocalLib: boolean; /** True when discovered inside `import("…")` rather than a static import. */ isDynamic: boolean; + /** True when discovered in a bare `import "…"` statement. */ + isSideEffect?: boolean; }; /** Resolved dependency after its source module has been transformed to a temp file. */ @@ -80,7 +85,7 @@ const matchAlias = (specifier: string) => specifier.startsWith("@/") ? specifier const matchRelative = (specifier: string) => specifier.match(/^(\.\.?\/[^?]+)(?:\?.*)?$/)?.[1]; function collectAliasImports(fileContent: string): AliasImport[] { - const toAlias = (isDynamic: boolean) => + const toAlias = (isDynamic: boolean, isSideEffect = false) => ( { original, path, start, end }: { original: string; @@ -88,13 +93,17 @@ function collectAliasImports(fileContent: string): AliasImport[] { start: number; end: number; }, - ): AliasImport => ({ full: original, path, start, end, isDynamic }); + ): AliasImport => ({ full: original, path, start, end, isDynamic, isSideEffect }); return [ ...collectBoundedSpans( (maxMatches) => findStaticImportFromSpans(fileContent, matchAlias, maxMatches), "static alias", ).map(toAlias(false)), + ...collectBoundedSpans( + (maxMatches) => findStaticSideEffectImportSpans(fileContent, matchAlias, maxMatches), + "side-effect alias", + ).map(toAlias(false, true)), ...collectBoundedSpans( (maxMatches) => findDynamicImportSpans(fileContent, matchAlias, maxMatches), "dynamic alias", @@ -103,7 +112,7 @@ function collectAliasImports(fileContent: string): AliasImport[] { } function collectRelativeImports(fileContent: string, fileDir: string): RelativeImport[] { - const toRelative = (isDynamic: boolean) => + const toRelative = (isDynamic: boolean, isSideEffect = false) => ( { original, path, start, end }: { original: string; @@ -111,13 +120,25 @@ function collectRelativeImports(fileContent: string, fileDir: string): RelativeI start: number; end: number; }, - ): RelativeImport => ({ full: original, path, fromDir: fileDir, start, end, isDynamic }); + ): RelativeImport => ({ + full: original, + path, + fromDir: fileDir, + start, + end, + isDynamic, + isSideEffect, + }); return [ ...collectBoundedSpans( (maxMatches) => findStaticImportFromSpans(fileContent, matchRelative, maxMatches), "static relative", ).map(toRelative(false)), + ...collectBoundedSpans( + (maxMatches) => findStaticSideEffectImportSpans(fileContent, matchRelative, maxMatches), + "side-effect relative", + ).map(toRelative(false, true)), ...collectBoundedSpans( (maxMatches) => findDynamicImportSpans(fileContent, matchRelative, maxMatches), "dynamic relative", @@ -191,6 +212,7 @@ async function resolveRelativeImport( depFilePath, isLocalLib: false, isDynamic: imp.isDynamic, + isSideEffect: imp.isSideEffect, }; } @@ -233,9 +255,13 @@ export function rewriteResolvedDependencyImports( start: dep.start, end: dep.end, expected: dep.full, - // A dynamic span covers only the quoted specifier; a static one covers the - // whole `from "…"` clause. - replacement: dep.isDynamic ? `"${moduleUrl}"` : `from "${moduleUrl}"`, + // A dynamic span covers only the quoted specifier, a side-effect span + // covers the bare import, and a static binding span covers `from "…"`. + replacement: dep.isSideEffect + ? `import "${moduleUrl}"` + : dep.isDynamic + ? `"${moduleUrl}"` + : `from "${moduleUrl}"`, }; }); return replaceSourceSpans(fileContent, replacements); diff --git a/src/rendering/orchestrator/module-loader/index.test.ts b/src/rendering/orchestrator/module-loader/index.test.ts index 23e21bf1da..99ad59f834 100644 --- a/src/rendering/orchestrator/module-loader/index.test.ts +++ b/src/rendering/orchestrator/module-loader/index.test.ts @@ -10,15 +10,22 @@ import { import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import { getLocalAdapter } from "#veryfront/platform/adapters/registry.ts"; import { basename, dirname, join } from "#veryfront/compat/path/index.ts"; -import { runWithCacheDir } from "#veryfront/utils/cache-dir.ts"; +import { getMdxEsmCacheDir, runWithCacheDir } from "#veryfront/utils/cache-dir.ts"; +import { buildMdxEsmPathCacheKey } from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts"; import { isMissingModuleError, + isUnresolvedTenantImport, loadModule, type ModuleLoaderConfig, transformModuleWithDeps, } from "./index.ts"; import { buildModuleTransformCacheVariant, getModuleCacheKey } from "./module-cache-lookup.ts"; -import { isBuildFailure } from "./build-failure.ts"; +import { + isBuildFailure, + isTenantBuildFailure, + markBuildFailure, + markTenantBuildFailure, +} from "./build-failure.ts"; async function withModuleLoaderFixture( files: Record, @@ -283,6 +290,163 @@ describe("module-loader/transformModuleWithDeps", () => { }); describe("module-loader/loadModule build-failure tagging", () => { + it("ignores inherited build-failure tags", () => { + const buildFailureTag = Symbol.for("veryfront.module-loader.build-failure"); + const tenantBuildFailureTag = Symbol.for("veryfront.module-loader.tenant-build-failure"); + const previousBuildDescriptor = Object.getOwnPropertyDescriptor( + Error.prototype, + buildFailureTag, + ); + const previousTenantDescriptor = Object.getOwnPropertyDescriptor( + Error.prototype, + tenantBuildFailureTag, + ); + Object.defineProperty(Error.prototype, buildFailureTag, { configurable: true, value: true }); + Object.defineProperty(Error.prototype, tenantBuildFailureTag, { + configurable: true, + value: true, + }); + + try { + const frameworkError = new Error("framework failed"); + assertEquals(isBuildFailure(frameworkError), false); + assertEquals(isTenantBuildFailure(frameworkError), false); + + const tenantError = new Error("tenant failed"); + assertStrictEquals(markTenantBuildFailure(tenantError), tenantError); + assertEquals(isBuildFailure(tenantError), true); + assertEquals(isTenantBuildFailure(tenantError), true); + + const accessorTagError = new Error("framework failed"); + let buildGetterRead = false; + let tenantGetterRead = false; + Object.defineProperty(accessorTagError, buildFailureTag, { + configurable: true, + get() { + buildGetterRead = true; + return true; + }, + }); + Object.defineProperty(accessorTagError, tenantBuildFailureTag, { + configurable: true, + get() { + tenantGetterRead = true; + return true; + }, + }); + + assertEquals(isBuildFailure(accessorTagError), false); + assertEquals(isTenantBuildFailure(accessorTagError), false); + assertEquals(buildGetterRead, false); + assertEquals(tenantGetterRead, false); + } finally { + if (previousBuildDescriptor) { + Object.defineProperty(Error.prototype, buildFailureTag, previousBuildDescriptor); + } else { + delete (Error.prototype as { [buildFailureTag]?: unknown })[buildFailureTag]; + } + if (previousTenantDescriptor) { + Object.defineProperty(Error.prototype, tenantBuildFailureTag, previousTenantDescriptor); + } else { + delete (Error.prototype as { [tenantBuildFailureTag]?: unknown })[tenantBuildFailureTag]; + } + } + }); + + it("rejects prototype-polluted accessor tag descriptors", () => { + const buildFailureTag = Symbol.for("veryfront.module-loader.build-failure"); + const tenantBuildFailureTag = Symbol.for("veryfront.module-loader.tenant-build-failure"); + const previousDescriptorValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + const previousHasOwnProperty = Object.getOwnPropertyDescriptor( + Object.prototype, + "hasOwnProperty", + ); + assert(previousHasOwnProperty); + const frameworkError = new Error("framework failed"); + Object.defineProperty(frameworkError, buildFailureTag, { + configurable: true, + get: undefined, + set: undefined, + }); + Object.defineProperty(frameworkError, tenantBuildFailureTag, { + configurable: true, + get: undefined, + set: undefined, + }); + Object.defineProperty(Object.prototype, "hasOwnProperty", { + ...previousHasOwnProperty, + value: () => true, + }); + Object.defineProperty(Object.prototype, "value", { configurable: true, value: true }); + + try { + assertEquals(isBuildFailure(frameworkError), false); + assertEquals(isTenantBuildFailure(frameworkError), false); + } finally { + if (previousDescriptorValue) { + Object.defineProperty(Object.prototype, "value", previousDescriptorValue); + } else { + delete (Object.prototype as { value?: unknown }).value; + } + Object.defineProperty(Object.prototype, "hasOwnProperty", previousHasOwnProperty); + } + }); + + it("ignores poisoned Reflect descriptor lookups when reading build-failure tags", () => { + const previousDescriptor = Object.getOwnPropertyDescriptor( + Reflect, + "getOwnPropertyDescriptor", + ); + assert(previousDescriptor); + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", { + ...previousDescriptor, + value: () => ({ + configurable: true, + enumerable: false, + value: true, + writable: false, + }), + }); + + try { + const frameworkError = new Error("framework failed"); + assertEquals(isBuildFailure(frameworkError), false); + assertEquals(isTenantBuildFailure(frameworkError), false); + } finally { + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", previousDescriptor); + } + }); + + it("uses the definition intrinsic captured during module initialization", () => { + const tenantBuildFailureTag = Symbol.for("veryfront.module-loader.tenant-build-failure"); + const defineProperty = Object.defineProperty; + const previous = Object.getOwnPropertyDescriptor(Object, "defineProperty"); + if (!previous || typeof previous.value !== "function") { + throw new Error("Expected Object.defineProperty descriptor"); + } + defineProperty(Object, "defineProperty", { + ...previous, + value: (target: object, tag: PropertyKey, descriptor: PropertyDescriptor) => { + defineProperty(target, tenantBuildFailureTag, { configurable: true, value: true }); + return defineProperty(target, tag, descriptor); + }, + }); + + try { + const frameworkError = new Error("framework failed"); + assertStrictEquals(markBuildFailure(frameworkError), frameworkError); + assertEquals(isBuildFailure(frameworkError), true); + assertEquals(isTenantBuildFailure(frameworkError), false); + + const tenantError = new Error("tenant failed"); + assertStrictEquals(markTenantBuildFailure(tenantError), tenantError); + assertEquals(isBuildFailure(tenantError), true); + assertEquals(isTenantBuildFailure(tenantError), true); + } finally { + defineProperty(Object, "defineProperty", previous); + } + }); + // Compiling a real page module starts esbuild's child process; stop it so the // test does not leak the handle rather than opting out of the sanitizer. afterAll(async () => { @@ -335,6 +499,552 @@ describe("module-loader/loadModule build-failure tagging", () => { }, ); }); + + // A relative import that resolves to nothing is dropped by + // `resolveModuleDependencies` and survives into the built module as authored, + // so the failure only surfaces at `import()` time as ERR_MODULE_NOT_FOUND — + // after the self-heal rebuild has already retried it. That rejection used to + // leave `loadModule` untagged, so a tenant typo in an import path was + // reported at error level forever. + it("tags a missing local static import as a tenant build failure", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./missing";`, + `export default function Page() { return label; }`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + it("tags a missing bare side-effect import as a tenant build failure", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import "./missing";`, + `export default function Page() { return null; }`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + it("tags a missing project alias import as a tenant build failure", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "@/components/Missing";`, + `export default function Page() { return label; }`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + it("tags a missing project alias import with an explicit source extension", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "@/components/Missing.tsx";`, + `export default function Page() { return label; }`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + it("tags missing side-effect imports in every legal declaration position", async () => { + for ( + const source of [ + `const ready = true; import "./missing"; export default ready;`, + `import /* preload */ "./missing"; export default null;`, + ] + ) { + await withModuleLoaderFixture( + { "app/page.tsx": source }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + } + }); + + it("classifies retry failures from only the rebuilt dependency graph", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./late";`, + `export default function Page() { return label; }`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + let createdLateDependency = false; + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => + loadModule(join(projectDir, "app/page.tsx"), { + ...config, + onProgress: ({ phase, filePath }) => { + if ( + createdLateDependency || + phase !== "module:persisted" || + filePath !== join(projectDir, "app/page.tsx") + ) return; + + createdLateDependency = true; + Deno.writeTextFileSync( + join(projectDir, "app/late.ts"), + [ + `import "./framework-missing";`, + `export const label = "late";`, + ].join("\n"), + ); + }, + }), + Error, + ); + + assertEquals(createdLateDependency, true); + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + // The first transform dropped `./late`, but the rebuild resolved it. + // Classification must come from the dependency's separate bare + // side-effect failure, not stale evidence from build one. + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + // A transform cache hit skips dependency resolution, and the retry path + // invalidates only the root module's cache entry — so on the rebuild the + // dependency holding the typo is served from cache and contributes no + // evidence. Combined with clearing the set before the rebuild, that would + // leave a dependency-level typo permanently unattributed, including on the + // very first load. The cache-hit branch replays each module's recorded + // specifiers to close it. Both loads must classify identically. + it("attributes a typo in a cached dependency on every load", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./dep";`, + `export default function Page() { return label; }`, + ].join("\n"), + "app/dep.tsx": [ + `import { gone } from "./gone";`, + `export const label = gone;`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const pagePath = join(projectDir, "app/page.tsx"); + + const first = await assertRejects(() => loadModule(pagePath, config), Error); + assertEquals(isBuildFailure(first), true); + assertEquals(isTenantBuildFailure(first), true); + + // Same config, so `config.moduleCache` is warm for `app/dep.tsx`. + const second = await assertRejects(() => loadModule(pagePath, config), Error); + assertEquals(isBuildFailure(second), true); + // Identical failure must not get weaker attribution just because a + // dependency happened to be cached. + assertEquals(isTenantBuildFailure(second), true); + }); + }, + ); + }); + + it("attributes a typo below a cached dependency on every load", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./dep";`, + `export default function Page() { return label; }`, + ].join("\n"), + "app/dep.tsx": [ + `import { nested } from "./nested";`, + `export const label = nested;`, + ].join("\n"), + "app/nested.tsx": [ + `import { gone } from "./gone";`, + `export const nested = gone;`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const pagePath = join(projectDir, "app/page.tsx"); + + const first = await assertRejects(() => loadModule(pagePath, config), Error); + assertEquals(isBuildFailure(first), true); + assertEquals(isTenantBuildFailure(first), true); + + const second = await assertRejects(() => loadModule(pagePath, config), Error); + assertEquals(isBuildFailure(second), true); + assertEquals(isTenantBuildFailure(second), true); + }); + }, + ); + }); + + it("attributes a typo replayed from a disk-cached dependency", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./dep";`, + `export default function Page() { return label; }`, + ].join("\n"), + "app/dep.tsx": [ + `import { gone } from "./gone";`, + `export const label = gone;`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const pagePath = join(projectDir, "app/page.tsx"); + const diskConfig = { + ...config, + projectId: "disk-cache-project", + contentSourceId: "main", + }; + + const first = await assertRejects(() => loadModule(pagePath, diskConfig), Error); + assertEquals(isTenantBuildFailure(first), true); + + // Mode is part of the process-local cache key but not the persisted + // MDX path-cache key. Switching it gives this simulated new worker an + // empty evidence memo while reusing the dependency from _index.json. + const restartedConfig = { + ...diskConfig, + mode: "production" as const, + moduleCache: new Map(), + }; + const second = await assertRejects(() => loadModule(pagePath, restartedConfig), Error); + assertEquals(isBuildFailure(second), true); + assertEquals(isTenantBuildFailure(second), true); + }); + }, + ); + }); + + it("ignores legacy disk cache entries that predate unresolved-import sidecars", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { label } from "./dep";`, + `export default function Page() { return label; }`, + ].join("\n"), + "app/dep.tsx": [ + `import { gone } from "./gone";`, + `export const label = gone;`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const diskConfig = { + ...config, + projectId: "legacy-cache-project", + contentSourceId: "main", + }; + const legacyCacheDir = join( + getMdxEsmCacheDir(), + encodeURIComponent(diskConfig.projectId), + encodeURIComponent(diskConfig.contentSourceId), + ); + await Deno.mkdir(join(legacyCacheDir, "app"), { recursive: true }); + const legacyDepArtifact = join(legacyCacheDir, "app/dep.legacy.js"); + await Deno.writeTextFile( + legacyDepArtifact, + [`import { gone } from "./gone";`, `export const label = gone;`].join("\n"), + ); + const legacyPathKey = `mdx-esm-ec841873:19.1.1:_vf_modules/app/dep.js`; + assertEquals( + legacyPathKey === buildMdxEsmPathCacheKey("_vf_modules/app/dep.js", "19.1.1"), + false, + ); + await Deno.writeTextFile( + join(legacyCacheDir, "_index.json"), + JSON.stringify({ [legacyPathKey]: legacyDepArtifact }), + ); + + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), diskConfig), + Error, + ); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + it("attributes an executed dynamic dependency that failed to transform", async () => { + await withModuleLoaderFixture( + { + "app/page.ts": [ + `const dependency = await import("./broken");`, + `export const value = dependency.value;`, + ].join("\n"), + "app/broken.ts": `export const value: = "broken";`, + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.ts"), config), + Error, + ); + + assertEquals(isMissingModuleError(error), true); + assertEquals(isBuildFailure(error), true); + assertEquals(isTenantBuildFailure(error), true); + }); + }, + ); + }); + + // The same seam must not launder a framework fault. A module whose imports + // all resolve, and which then throws while executing, is an application + // error: it must come back out of `loadModule` untagged on both predicates. + // Asserting this through the real fixture rather than on a hand-built error + // is the point — a constructed Error never enters `loadModule`, so it would + // pass identically if the classification branch were deleted or inverted. + it("leaves a resolvable import that throws at module scope untagged", async () => { + await withModuleLoaderFixture( + { + "app/page.tsx": [ + `import { boom } from "./dep";`, + `export default function Page() { return boom; }`, + ].join("\n"), + "app/dep.tsx": [ + `throw new Error("dependency exploded at module scope");`, + `export const boom = "unreachable";`, + ].join("\n"), + }, + async ({ projectDir, tmpDir, config }) => { + await runWithCacheDir(tmpDir, async () => { + const error = await assertRejects( + () => loadModule(join(projectDir, "app/page.tsx"), config), + Error, + "dependency exploded at module scope", + ); + + assertEquals(isMissingModuleError(error), false); + assertEquals(isBuildFailure(error), false); + assertEquals(isTenantBuildFailure(error), false); + }); + }, + ); + }); +}); + +// The retry seam sees `ERR_MODULE_NOT_FOUND` for four different causes and may +// only downgrade one of them. `isMissingModuleError` cannot tell them apart, so +// the discrimination is driven by the specifiers the resolver recorded dropping. +describe("module-loader/isUnresolvedTenantImport", () => { + const REBUILT = "/tmp/out/veryfront-modules/proj-a/app/page.7f3c1d92.js"; + + // The runtime names the missing target first and then appends the importer's + // own location. At this seam the importer is always the rebuilt artifact, so + // every real message mentions REBUILT somewhere — which is exactly why the + // predicate may only inspect the first quoted token. + const missing = () => + Object.assign( + new TypeError( + 'Module not found "file:///tmp/out/veryfront-modules/proj-a/app/missing".\n' + + ` at file://${REBUILT}:1:23`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + it("classifies a dropped tenant specifier as tenant source", () => { + assertEquals(isUnresolvedTenantImport(missing(), new Set(["./missing"])), true); + }); + + it("classifies a dropped project alias after its SSR rewrite", () => { + const aliasMissing = Object.assign( + new TypeError( + 'Module not found "file:///tmp/out/veryfront-modules/proj-a/_vf_modules/components/Foo.js".\n' + + ` at file://${REBUILT}:1:23`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals( + isUnresolvedTenantImport(aliasMissing, new Set(["@/components/Foo"]), REBUILT), + true, + ); + }); + + it("classifies an explicit project alias source extension after its SSR rewrite", () => { + const aliasMissing = Object.assign( + new TypeError( + 'Module not found "file:///tmp/out/veryfront-modules/proj-a/_vf_modules/components/Missing.js".\n' + + ` at file://${REBUILT}:1:23`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals( + isUnresolvedTenantImport(aliasMissing, new Set(["@/components/Missing.tsx"]), REBUILT), + true, + ); + }); + + it("does not classify an unrelated missing target alongside a dropped specifier", () => { + const unrelated = Object.assign( + new TypeError( + 'Module not found "file:///tmp/out/veryfront-modules/proj-a/app/cycle-alias".\n' + + ` at file://${REBUILT}:1:23`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals(isUnresolvedTenantImport(unrelated, new Set(["./missing"]), REBUILT), false); + }); + + // The cycle-breaking branch leaves a resolved target's specifier as authored + // and relies on an alias the code itself marks as not runtime-verified. That + // target resolved, so it is never recorded as dropped — and a framework path + // the repo openly marks unverified must not page as a tenant warning. + it("does not classify a failure when the resolver dropped nothing", () => { + assertEquals(isUnresolvedTenantImport(missing(), new Set()), false); + }); + + // Bundle misses are framework infrastructure with dedicated recovery on the + // outer branch, which the inner catch does not re-check. Exclude them even + // when the tenant separately has an unresolved import. + it("does not classify an HTTP-bundle miss even alongside a dropped specifier", () => { + const bundleError = Object.assign( + new TypeError( + 'Module not found "file:///tmp/veryfront-http-bundle/http-2b1f9c4e.mjs".', + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals(isUnresolvedTenantImport(bundleError, new Set(["./missing"])), false); + }); + + // The missing module can be the rebuilt artifact itself rather than one of + // its dependencies: a racing cache sweep or a failing cache volume can evict + // it between persist and import. That is repeated cache eviction — framework + // infrastructure — and must stay at error severity even when the tenant + // separately has an unresolved import. + it("does not classify an evicted rebuilt artifact, even alongside a dropped specifier", () => { + const evicted = Object.assign( + new TypeError(`Module not found "file://${REBUILT}?t=1&rebuilt=1".`), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals(isUnresolvedTenantImport(evicted, new Set(["./missing"]), REBUILT), false); + assertEquals(isUnresolvedTenantImport(evicted, new Set(["./missing"])), false); + }); + + // The regression this pins: the importer line also names the rebuilt + // artifact, so a whole-message `includes` would classify a tenant typo as + // framework and silently undo the fix. + it("still classifies a dropped specifier whose importer is the rebuilt artifact", () => { + const error = missing(); + + assertEquals(error.message.includes(REBUILT), true); + assertEquals(isUnresolvedTenantImport(error, new Set(["./missing"]), REBUILT), true); + }); + + // Node quotes the missing target with single quotes and leaves the importer + // unquoted: `Cannot find module '/…/missing' imported from /…/page.js`. + // A double-quote-only match returns "" there, which silently disables the + // eviction guard on the Node runtime while every Deno test still passes. + it("reads a single-quoted Node target so the eviction guard still fires", () => { + const evictedOnNode = Object.assign( + new Error(`Cannot find module '${REBUILT}' imported from ${REBUILT}`), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals(isUnresolvedTenantImport(evictedOnNode, new Set(["./missing"]), REBUILT), false); + }); + + it("classifies a single-quoted Node target that is a dropped specifier", () => { + const nodeMissing = Object.assign( + new Error( + `Cannot find module '/tmp/out/veryfront-modules/proj-a/app/missing' ` + + `imported from ${REBUILT}`, + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); + + assertEquals(isUnresolvedTenantImport(nodeMissing, new Set(["./missing"]), REBUILT), true); + }); + + it("does not classify a failure that is not a resolution failure", () => { + assertEquals( + isUnresolvedTenantImport(new TypeError("x is not a function"), new Set(["./missing"])), + false, + ); + }); + + // The runtime reports the *resolved* path, which for a dropped relative + // specifier lands inside the build's own temp directory. A "does the message + // mention our temp dir?" heuristic would therefore reject the one case this + // predicate exists to catch. Pinned so nobody reintroduces it. + it("classifies a dropped specifier whose resolved path is inside the build temp dir", () => { + const tmpDir = "/tmp/out"; + const error = missing(); + + assertEquals(error.message.includes(tmpDir), true); + assertEquals(isUnresolvedTenantImport(error, new Set(["./missing"])), true); + }); }); describe("module-loader/loadModule", () => { diff --git a/src/rendering/orchestrator/module-loader/index.ts b/src/rendering/orchestrator/module-loader/index.ts index 39b84c13fb..43a0530d4f 100644 --- a/src/rendering/orchestrator/module-loader/index.ts +++ b/src/rendering/orchestrator/module-loader/index.ts @@ -19,21 +19,50 @@ import { rewriteResolvedDependencyImports, type TransformedModuleDependency, } from "./dependency-resolver.ts"; -import { persistTransformedModule } from "./module-persistence.ts"; +import { + persistTransformedModule, + readPersistedUnresolvedSpecifiers, +} from "./module-persistence.ts"; import { transformModuleCodeWithCache } from "./module-transform-cache.ts"; import { buildModuleTransformCacheVariant, getModuleCacheKey, resolveCachedModulePath, } from "./module-cache-lookup.ts"; -import { markBuildFailure } from "./build-failure.ts"; +import { markBuildFailure, markTenantBuildFailure } from "./build-failure.ts"; import type { TransformProgressListener } from "#veryfront/transforms/progress.ts"; import type { DependencyPinningSourceInput } from "#veryfront/transforms/esm/package-registry.ts"; +import { MODULE_CACHE_MAX_ENTRIES } from "#veryfront/utils/constants/cache.ts"; +import { isTenantSourceBuildError } from "#veryfront/errors/tenant-classification.ts"; +import { rewriteSsrProjectAliasSpecifier } from "#veryfront/transforms/import-rewriter/strategies/alias-strategy.ts"; export { isBuildFailure } from "./build-failure.ts"; const logger = rendererLogger.component("module-loader"); +/** + * Specifiers each transformed module subtree left as authored, keyed by the + * root module's transform cache key. + * + * The transform cache lets a module skip dependency resolution entirely, so the + * evidence has to outlive the resolution that produced it — otherwise a + * dependency's dangling tenant import is only ever visible on the very first + * transform. The memo uses the module cache's entry bound and refreshes access + * order on reads, while holding only specifier strings. + */ +const unresolvedSpecifiersByCacheKey = new Map(); + +function cacheUnresolvedSpecifiers(cacheKey: string, specifiers: readonly string[]): void { + unresolvedSpecifiersByCacheKey.delete(cacheKey); + unresolvedSpecifiersByCacheKey.set(cacheKey, specifiers); + + while (unresolvedSpecifiersByCacheKey.size > MODULE_CACHE_MAX_ENTRIES) { + const oldestKey = unresolvedSpecifiersByCacheKey.keys().next().value; + if (oldestKey === undefined) break; + unresolvedSpecifiersByCacheKey.delete(oldestKey); + } +} + function throwIfModuleLoadAborted(config: ModuleLoaderConfig): void { config.signal?.throwIfAborted(); } @@ -82,6 +111,19 @@ export async function transformModuleWithDeps( // ancestor that eventually persists that target reads it to write a stable // alias the left-as-authored cycle edge can resolve to. cycleTargets: Set = new Set(), + // Also shared by reference across the whole transform tree: every specifier + // the dependency resolver could not resolve and therefore left as authored. + // Those are the only specifiers that can survive into the built module and + // fail at `import()` time, so this is the evidence that tells a tenant typo + // apart from a framework artifact going missing. See `loadModule`. + // + // A module served from the cache below returns before + // `resolveModuleDependencies` runs, so it cannot re-derive its own evidence. + // That matters because the retry path invalidates only the *root* module's + // cache entry: without a memo, a typo living in a dependency would go + // unrecorded on every rebuild and never be attributed to the tenant. The + // cache-hit branch therefore replays what the first resolution found. + unresolvedSpecifiers: Set = new Set(), ): Promise { throwIfModuleLoadAborted(config); const { moduleCache, projectDir, projectId, contentSourceId, adapter, mode } = config; @@ -108,10 +150,26 @@ export async function transformModuleWithDeps( moduleServerOrigin: config.moduleServerOrigin, }); if (cachedPath) { + // Replay the evidence this module produced when it was last resolved. A + // cache hit skips `resolveModuleDependencies`, so without this a dependency + // that was already transformed contributes nothing and its tenant-authored + // dangling import silently loses attribution. + const memoizedUnresolvedSpecifiers = unresolvedSpecifiersByCacheKey.get(cacheKey); + const cachedUnresolvedSpecifiers = memoizedUnresolvedSpecifiers ?? + await readPersistedUnresolvedSpecifiers(cachedPath, localAdapter); + cacheUnresolvedSpecifiers(cacheKey, cachedUnresolvedSpecifiers); + for (const specifier of cachedUnresolvedSpecifiers) { + unresolvedSpecifiers.add(specifier); + } markModuleLoadProgress(config, "module:cache-hit", filePath); return cachedPath; } + // Collect this module and every recursively transformed descendant into an + // isolated set. Once persistence succeeds, cache that complete subtree and + // merge it into the caller's aggregate evidence. + const moduleUnresolvedSpecifiers = new Set(); + const readAdapter = useLocalAdapter ? localAdapter : adapter; let fileContent = decodeFileContent(await readAdapter.fs.readFile(filePath)); markModuleLoadProgress(config, "module:source-read", filePath); @@ -168,6 +226,7 @@ export async function transformModuleWithDeps( dep.isLocalLib, nextLineage, cycleTargets, + moduleUnresolvedSpecifiers, ); return { ...dep, depTempPath }; @@ -177,6 +236,14 @@ export async function transformModuleWithDeps( // branch must not fail the page that merely mentions it. if (!dep.isDynamic) throw error; + // A tenant-source compile failure is deliberately non-fatal until this + // dynamic edge executes. The importer remains authored, so retain that + // provenance for the retry classification seam. Infrastructure errors + // stay framework-owned even if the resulting edge is later missing. + if (isTenantSourceBuildError(error)) { + moduleUnresolvedSpecifiers.add(dep.path); + } + logger.warn("Leaving an unresolvable dynamic dependency as authored:", { path: dep.path, depFilePath: dep.depFilePath, @@ -198,13 +265,13 @@ export async function transformModuleWithDeps( for (const dep of resolvedDeps) { if (dep.depFilePath) continue; + moduleUnresolvedSpecifiers.add(dep.path); logger.warn("Could not find dependency:", { path: dep.path, relativePath: dep.relativePath, projectDir, }); } - const effectiveProjectId = projectId ?? projectDir; const { code: transformedCode } = await transformModuleCodeWithCache({ fileContent, @@ -236,7 +303,10 @@ export async function transformModuleWithDeps( moduleServerOrigin: config.moduleServerOrigin, dependencyPinningCacheKey: config.dependencyPinningCacheKey, isCycleTarget: cycleTargets.has(filePath), + unresolvedSpecifiers: [...moduleUnresolvedSpecifiers], }); + cacheUnresolvedSpecifiers(cacheKey, [...moduleUnresolvedSpecifiers]); + for (const specifier of moduleUnresolvedSpecifiers) unresolvedSpecifiers.add(specifier); markModuleLoadProgress(config, "module:persisted", filePath); return persistedPath; } @@ -300,6 +370,100 @@ export function isMissingModuleError(error: unknown): boolean { return /cannot find module|module not found/i.test(error.message); } +/** + * Whether a module-not-found failure is a specifier the project authored that + * points at nothing, as opposed to framework infrastructure going missing. + * + * `isMissingModuleError` alone cannot answer this: an `ERR_MODULE_NOT_FOUND` + * is raised the same way for a tenant typo, an HTTP bundle miss, a cycle-break + * alias that did not resolve, and a rebuilt artifact the runtime failed to + * persist. Only the first is the tenant's fault, and only the first may be + * downgraded to a warning in observability. + * + * The discriminator is evidence rather than a guess: `resolveModuleDependencies` + * resolves only `@/` aliases and relative imports, and `transformModuleWithDeps` + * records every specifier it had to leave as authored. If it left none anywhere + * in this module's transform tree, then nothing tenant-authored survived + * unrewritten and whatever is missing here is framework-owned. + * + * Note the runtime reports the *resolved* path, which for a dropped relative + * specifier lands inside the build's own temp directory — so a "is this path + * ours?" test on the message would reject exactly the case this identifies. + */ +/** + * The specifier a module-not-found error names as missing. + * + * Runtimes report it as the first quoted token and then name the importer, so + * only the first quote pair identifies what is actually absent. The quote style + * differs by runtime and both reach this seam, since `isMissingModuleError` + * matches Node's phrasing as well as Deno's: + * + * - Deno: `Module not found "file:///…/missing".` + * - Node: `Cannot find module '/…/missing' imported from /…/page.js` + * + * Note Node leaves the importer unquoted, so a double-quote-only match would + * return `""` there and silently disable every check built on this. + */ +function missingModuleTarget(message: string): string { + const match = message.match(/"([^"]*)"|'([^']*)'/); + return match?.[1] ?? match?.[2] ?? ""; +} + +function normalizeMissingModuleTarget(message: string): string { + const target = missingModuleTarget(message).replace(/[?#].*$/, ""); + if (target.startsWith("file://")) { + try { + return decodeURIComponent(new URL(target).pathname); + } catch { + return target.replace(/^file:\/+/, "/"); + } + } + return target; +} + +function normalizeUnresolvedSpecifier(specifier: string): string { + const withoutSuffix = specifier.replace(/[?#].*$/, ""); + return (rewriteSsrProjectAliasSpecifier(withoutSuffix) ?? withoutSuffix) + .replace(/^(\.\/|\.\.\/)+/, "") + .replace(/^\/+/, ""); +} + +function missingTargetMatchesSpecifier(target: string, specifier: string): boolean { + const normalizedSpecifier = normalizeUnresolvedSpecifier(specifier); + if (!normalizedSpecifier) return false; + return target === normalizedSpecifier || target.endsWith(`/${normalizedSpecifier}`); +} + +export function isUnresolvedTenantImport( + error: unknown, + unresolvedSpecifiers: ReadonlySet, + rebuiltArtifactPath?: string, +): boolean { + if (!isMissingModuleError(error)) return false; + if (unresolvedSpecifiers.size === 0) return false; + const message = error instanceof Error ? error.message : String(error); + // An HTTP bundle is framework infrastructure with dedicated recovery on the + // outer branch. A miss on one is not the tenant's doing even when the tenant + // separately has an unresolved import. + if (/veryfront-http-bundle\/http-[a-f0-9]+\.mjs/.test(message)) return false; + // The missing module can be the rebuilt artifact *itself* rather than one of + // its dependencies — a racing cache sweep or a failing cache volume can evict + // it between persist and import. That is repeated cache eviction, which must + // stay at error severity however the tenant's own imports look. + // + // Only the *missing target* may be compared, never the whole message: the + // runtime appends the importer's location, and at this seam the importer is + // always the rebuilt artifact, so scanning the full message would exclude + // every case including the tenant typo this predicate exists to catch. + if (rebuiltArtifactPath && missingModuleTarget(message).includes(rebuiltArtifactPath)) { + return false; + } + const missingTarget = normalizeMissingModuleTarget(message); + return [...unresolvedSpecifiers].some((specifier) => + missingTargetMatchesSpecifier(missingTarget, specifier) + ); +} + /** * Load a module by path, transforming it and its dependencies. * @@ -318,9 +482,23 @@ export async function loadModule( // Everything up to here compiles and resolves source, so a failure is a build // failure. Everything after it is the module running. + // Every specifier the resolver had to leave as authored, across this module's + // whole transform tree. Read back at the retry seam below to tell a tenant + // typo apart from framework infrastructure going missing. + const unresolvedSpecifiers = new Set(); + let tempFilePath: string; try { - tempFilePath = await transformModuleWithDeps(filePath, tmpDir, localAdapter, config); + tempFilePath = await transformModuleWithDeps( + filePath, + tmpDir, + localAdapter, + config, + false, + undefined, + undefined, + unresolvedSpecifiers, + ); } catch (error) { throw markBuildFailure(error); } @@ -396,14 +574,51 @@ export async function loadModule( ), ); + // Classification at the retry seam must describe the rebuilt graph, not + // the artifact that just failed. A dependency may appear between the two + // transforms, so retaining its earlier dropped-specifier evidence can + // misattribute an unrelated retry failure to the tenant. + unresolvedSpecifiers.clear(); + let rebuiltPath: string; try { - rebuiltPath = await transformModuleWithDeps(filePath, tmpDir, localAdapter, config); + rebuiltPath = await transformModuleWithDeps( + filePath, + tmpDir, + localAdapter, + config, + false, + undefined, + undefined, + unresolvedSpecifiers, + ); } catch (rebuildError) { throw markBuildFailure(rebuildError); } - return await import(`${toFileUrl(rebuiltPath).href}?t=${Date.now()}&rebuilt=1`); + try { + return await import(`${toFileUrl(rebuiltPath).href}?t=${Date.now()}&rebuilt=1`); + } catch (retryError) { + // The module was found and ran, so it threw at module scope. That is an + // ordinary application error the project's own error page should + // present, not a build failure — leave it untagged. + if (!isMissingModuleError(retryError)) throw retryError; + + // Still unresolved after a full rebuild from source, and the resolver + // recorded leaving a specifier as authored: a path the project wrote + // that points at nothing. Classify it explicitly, because + // `ERR_MODULE_NOT_FOUND` is not a VeryfrontError and slug-based + // classification cannot see it. + if (isUnresolvedTenantImport(retryError, unresolvedSpecifiers, rebuiltPath)) { + throw markTenantBuildFailure(retryError); + } + + // A resolution failure the tenant did not cause: an HTTP bundle miss, a + // cycle-break alias that did not resolve, or an artifact the rebuild + // failed to persist. Still a build failure, but a framework-owned one, + // so it keeps error-level severity. + throw markBuildFailure(retryError); + } } logger.error("Failed to import module:", { diff --git a/src/rendering/orchestrator/module-loader/module-persistence.test.ts b/src/rendering/orchestrator/module-loader/module-persistence.test.ts index 955dbd4e3b..fb163389f9 100644 --- a/src/rendering/orchestrator/module-loader/module-persistence.test.ts +++ b/src/rendering/orchestrator/module-loader/module-persistence.test.ts @@ -5,8 +5,14 @@ import { basename, dirname, join } from "#veryfront/compat/path/index.ts"; import { getLocalAdapter } from "#veryfront/platform/adapters/registry.ts"; import { hashCodeHex } from "#veryfront/utils/hash-utils.ts"; import { getModulePathCache } from "#veryfront/transforms/mdx/esm-module-loader/cache/index.ts"; -import { buildMdxEsmPathCacheKey } from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts"; -import { persistTransformedModule } from "./module-persistence.ts"; +import { + buildMdxEsmPathCacheKey, + UNRESOLVED_IMPORTS_SIDECAR_SUFFIX, +} from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts"; +import { + persistTransformedModule, + readPersistedUnresolvedSpecifiers, +} from "./module-persistence.ts"; describe("module-loader/module-persistence", () => { it("writes transformed code, registers MDX path-cache, and updates module cache", async () => { @@ -22,6 +28,8 @@ describe("module-loader/module-persistence", () => { await Deno.mkdir(dirname(filePath), { recursive: true }); await Deno.writeTextFile(filePath, "export const page = 1;"); + const unresolvedSpecifiers = ["./missing", "./nested-missing"]; + const result = await persistTransformedModule({ filePath, projectDir, @@ -32,12 +40,19 @@ describe("module-loader/module-persistence", () => { cacheKey, contentSourceId: "preview-main", reactVersion: "19.1.1", + unresolvedSpecifiers, }); - const expectedHash = hashCodeHex(transformedCode).slice(0, 8); + const expectedHash = hashCodeHex( + `${transformedCode}\0${JSON.stringify(unresolvedSpecifiers)}`, + ).slice(0, 8); assertEquals(result, join(tmpDir, `app/page.${expectedHash}.js`)); assertEquals(await Deno.readTextFile(result), transformedCode); assertEquals(moduleCache.get(cacheKey), result); + assertEquals( + await readPersistedUnresolvedSpecifiers(result, localAdapter), + unresolvedSpecifiers, + ); const pathCache = await getModulePathCache(tmpDir); const mdxCacheKey = buildMdxEsmPathCacheKey("_vf_modules/app/page.js", "19.1.1"); @@ -99,6 +114,160 @@ describe("module-loader/module-persistence", () => { } }); + it("writes a JavaScript cycle target at the authored stable path", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); + const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); + const localAdapter = await getLocalAdapter(); + const filePath = join(projectDir, "app/page.js"); + const transformedCode = "export const page = 1;"; + try { + const result = await persistTransformedModule({ + filePath, + projectDir, + tmpDir, + transformedCode, + localAdapter, + moduleCache: new Map(), + cacheKey: "javascript-cycle-target", + isCycleTarget: true, + }); + + assertEquals(result, join(tmpDir, "app/page.js")); + assertEquals(await Deno.readTextFile(result), transformedCode); + } finally { + await Deno.remove(projectDir, { recursive: true }).catch(() => undefined); + await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); + } + }); + + it("does not infer a default cycle alias from string contents", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); + const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); + const localAdapter = await getLocalAdapter(); + const filePath = join(projectDir, "app/page.ts"); + const moduleCache = new Map(); + + try { + const result = await persistTransformedModule({ + filePath, + projectDir, + tmpDir, + transformedCode: `export const label = "Set as default";`, + localAdapter, + moduleCache, + cacheKey: "string-default", + isCycleTarget: true, + }); + + const aliasCode = await Deno.readTextFile(join(dirname(result), "page.js")); + assertEquals(aliasCode, `export * from "./${basename(result)}";`); + } finally { + await Deno.remove(projectDir, { recursive: true }).catch(() => undefined); + await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); + } + }); + + it("does not infer a default cycle alias from regex contents", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); + const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); + const localAdapter = await getLocalAdapter(); + + const cases = [ + `export const pattern = /export default/;`, + `if (enabled) /export default/.test(source); export const value = 1;`, + `if (enabled) {} /export default/.test(source); export const value = 1;`, + `function setup() {} /export default/.test(source); export const value = 1;`, + `function setup({ nested: {} } = {}) {} /export default/.test(source); export const value = 1;`, + `class Setup {} /export default/.test(source); export const value = 1;`, + `class Setup extends mixin({}) {} /export default/.test(source); export const value = 1;`, + `function read() { return /* keep the comment */ /export default/.source; }`, + `function read() { return // keep the comment\n/export default/.source; }`, + ] as const; + + try { + for (const [index, transformedCode] of cases.entries()) { + const filePath = join(projectDir, `app/page-${index}.ts`); + const result = await persistTransformedModule({ + filePath, + projectDir, + tmpDir, + transformedCode, + localAdapter, + moduleCache: new Map(), + cacheKey: `regex-default-${index}`, + isCycleTarget: true, + }); + + const aliasCode = await Deno.readTextFile(join(dirname(result), `page-${index}.js`)); + assertEquals(aliasCode, `export * from "./${basename(result)}";`); + } + } finally { + await Deno.remove(projectDir, { recursive: true }).catch(() => undefined); + await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); + } + }); + + it("writes default cycle aliases only when an export exposes default", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); + const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); + const localAdapter = await getLocalAdapter(); + const moduleCache = new Map(); + + const cases = [ + { + path: "app/default-declaration.ts", + transformedCode: `export default function Page() { return null; }`, + exposesDefault: true, + }, + { + path: "app/division-before-default.ts", + transformedCode: `const ratio = total / count; export default ratio;`, + exposesDefault: true, + }, + { + path: "app/member-keyword-division-before-default.ts", + transformedCode: `const ratio = mod.typeof / 2; export { default } from "./component.js";`, + exposesDefault: true, + }, + { + path: "app/named-as-default.ts", + transformedCode: `const Page = () => null;\nexport { Page as default };`, + exposesDefault: true, + }, + { + path: "app/default-as-named.ts", + transformedCode: `export { default as Page } from "./component.js";`, + exposesDefault: false, + }, + ] as const; + + try { + for (const testCase of cases) { + const result = await persistTransformedModule({ + filePath: join(projectDir, testCase.path), + projectDir, + tmpDir, + transformedCode: testCase.transformedCode, + localAdapter, + moduleCache, + cacheKey: testCase.path, + isCycleTarget: true, + }); + + const aliasCode = await Deno.readTextFile( + join(dirname(result), basename(testCase.path).replace(/\.ts$/, ".js")), + ); + assertEquals( + aliasCode.includes(`export { default } from "./${basename(result)}";`), + testCase.exposesDefault, + ); + } + } finally { + await Deno.remove(projectDir, { recursive: true }).catch(() => undefined); + await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); + } + }); + it("recreates the output directory when it disappears after being cached", async () => { const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); @@ -279,4 +448,54 @@ describe("module-loader/module-persistence", () => { await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); } }); + + it("keeps the artifact available without caching when unresolved-import evidence cannot be written", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-module-persist-project-" }); + const tmpDir = await Deno.makeTempDir({ prefix: "vf-module-persist-out-" }); + const localAdapter = await getLocalAdapter(); + const filePath = join(projectDir, "lib/evidence.ts"); + const moduleCache = new Map(); + const transformedCode = "export const evidence = true;"; + + const stubFs = Object.create(localAdapter.fs) as typeof localAdapter.fs; + stubFs.writeFile = (path: string, content: string) => { + if (path.endsWith(UNRESOLVED_IMPORTS_SIDECAR_SUFFIX)) { + return Promise.reject(new Error("ENOSPC: no space left on device")); + } + return localAdapter.fs.writeFile(path, content); + }; + const stubAdapter = Object.create(localAdapter) as typeof localAdapter; + Object.defineProperty(stubAdapter, "fs", { value: stubFs }); + + try { + await Deno.mkdir(dirname(filePath), { recursive: true }); + + const result = await persistTransformedModule({ + filePath, + projectDir, + tmpDir, + transformedCode, + localAdapter: stubAdapter, + moduleCache, + cacheKey: "evidence", + contentSourceId: "preview-main", + reactVersion: "19.1.1", + unresolvedSpecifiers: ["./missing"], + isCycleTarget: true, + }); + + assertEquals(await Deno.readTextFile(result), transformedCode); + const aliasCode = await Deno.readTextFile(join(tmpDir, "lib/evidence.js")); + assertStringIncludes(aliasCode, `export * from "./${basename(result)}";`); + assertEquals(moduleCache.has("evidence"), false); + assertEquals(await readPersistedUnresolvedSpecifiers(result, stubAdapter), []); + + const pathCache = await getModulePathCache(tmpDir); + const mdxCacheKey = buildMdxEsmPathCacheKey("_vf_modules/lib/evidence.js", "19.1.1"); + assertEquals(pathCache.has(mdxCacheKey), false); + } finally { + await Deno.remove(projectDir, { recursive: true }).catch(() => undefined); + await Deno.remove(tmpDir, { recursive: true }).catch(() => undefined); + } + }); }); diff --git a/src/rendering/orchestrator/module-loader/module-persistence.ts b/src/rendering/orchestrator/module-loader/module-persistence.ts index 1adbb52534..aaf3b8bf29 100644 --- a/src/rendering/orchestrator/module-loader/module-persistence.ts +++ b/src/rendering/orchestrator/module-loader/module-persistence.ts @@ -8,12 +8,17 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { rendererLogger } from "#veryfront/utils"; import { isCacheWriteRaceError } from "#veryfront/utils/cache-file-ops.ts"; +import { isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { hashCodeHex } from "#veryfront/utils/hash-utils.ts"; import { getModulePathCache, saveModulePathCache, } from "#veryfront/transforms/mdx/esm-module-loader/cache/index.ts"; -import { buildMdxEsmPathCacheKey } from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts"; +import { + buildMdxEsmPathCacheKey, + UNRESOLVED_IMPORTS_SIDECAR_SUFFIX, +} from "#veryfront/transforms/mdx/esm-module-loader/cache-format.ts"; +import { isMemberNameBefore } from "#veryfront/transforms/mdx/esm-module-loader/utils/source-spans.ts"; import { buildModuleTransformCacheVariant } from "./module-cache-lookup.ts"; const logger = rendererLogger.component("module-loader"); @@ -72,6 +77,8 @@ export interface PersistTransformedModuleInput { reactVersion?: string; dependencyPinningCacheKey?: string; moduleServerOrigin?: string; + /** Tenant-authored imports left unresolved in this module subtree. */ + unresolvedSpecifiers?: readonly string[]; /** * True when a dynamic import elsewhere closes a cycle back onto this module. * Such an edge is left as authored (`import("../app/page.js")`), so it needs a @@ -80,15 +87,399 @@ export interface PersistTransformedModuleInput { isCycleTarget?: boolean; } +/** Read unresolved-import evidence stored beside a transformed artifact. */ +export async function readPersistedUnresolvedSpecifiers( + modulePath: string, + localAdapter: RuntimeAdapter, +): Promise { + try { + const content = await localAdapter.fs.readFile( + `${modulePath}${UNRESOLVED_IMPORTS_SIDECAR_SUFFIX}`, + ); + const decoded = typeof content === "string" ? content : new TextDecoder().decode(content); + const parsed: unknown = JSON.parse(decoded); + if (!Array.isArray(parsed) || !parsed.every((value) => typeof value === "string")) { + return []; + } + return parsed; + } catch (error) { + if (isNotFoundError(error)) return []; + logger.debug("Unresolved-import cache evidence unavailable", { + modulePath: modulePath.slice(-60), + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + /** * Whether transformed output exposes a default export, so a cycle alias knows * to re-export it. Covers esbuild's `export default …`, `… as default`, and * `export { default } from …` forms. */ function hasDefaultExport(code: string): boolean { - return /\bexport\s+default\b/.test(code) || - /\bas\s+default\b/.test(code) || - /\bexport\s*\{[^}]*\bdefault\b[^}]*\}/.test(code); + let previousTokenIndex = -1; + const controlConditionCloseParens = new Set(); + const statementBlockCloseBraces = new Set(); + const openParens: boolean[] = []; + const openBraces: boolean[] = []; + let openBracketCount = 0; + let pendingDeclaration: + | { + kind: "class" | "function"; + braceDepth: number; + parenDepth: number; + bracketDepth: number; + parameterListClosed: boolean; + } + | undefined; + + for (let index = 0; index < code.length;) { + index = skipTrivia(code, index); + if (index >= code.length) break; + + if (startsIdentifier(code, index, "export")) { + const exportIndex = index; + index = skipTrivia(code, index + "export".length); + if (startsIdentifier(code, index, "default")) return true; + if (code[index] === "{" && exportListExposesDefault(code, index)) return true; + if (code[index] === "*") { + index = skipTrivia(code, index + 1); + if (startsIdentifier(code, index, "as")) { + index = skipTrivia(code, index + "as".length); + if (startsIdentifier(code, index, "default")) return true; + } + } + index = exportIndex + "export".length; + continue; + } + + const next = skipTextToken(code, index, { + previousTokenIndex, + controlConditionCloseParens, + statementBlockCloseBraces, + }); + if (next !== index) { + previousTokenIndex = next - 1; + index = next; + continue; + } + + if (isIdentifierStart(code[index])) { + const identifierStart = index; + index++; + while (index < code.length && isIdentifierPart(code[index])) index++; + const identifier = code.slice(identifierStart, index); + if ( + (identifier === "function" || identifier === "class") && + startsDeclaration(code, previousTokenIndex, controlConditionCloseParens) + ) { + pendingDeclaration = { + kind: identifier, + braceDepth: openBraces.length, + parenDepth: openParens.length, + bracketDepth: openBracketCount, + parameterListClosed: false, + }; + } + previousTokenIndex = index - 1; + continue; + } + + if (code[index] === "(") { + const keyword = identifierBefore(code, previousTokenIndex); + openParens.push( + keyword === "if" || keyword === "while" || keyword === "for" || + keyword === "with" || keyword === "switch" || keyword === "catch", + ); + } else if (code[index] === ")") { + if (openParens.pop() === true) controlConditionCloseParens.add(index); + if ( + pendingDeclaration?.kind === "function" && + openParens.length === pendingDeclaration.parenDepth + ) { + pendingDeclaration.parameterListClosed = true; + } + } else if (code[index] === "[") { + openBracketCount++; + } else if (code[index] === "]") { + openBracketCount = Math.max(0, openBracketCount - 1); + } else if (code[index] === "{") { + const opensDeclarationBody = pendingDeclaration !== undefined && + openBraces.length === pendingDeclaration.braceDepth && + openParens.length === pendingDeclaration.parenDepth && + openBracketCount === pendingDeclaration.bracketDepth && + (pendingDeclaration.kind === "class" || pendingDeclaration.parameterListClosed); + openBraces.push( + opensDeclarationBody || + code[previousTokenIndex] === ")" && + controlConditionCloseParens.has(previousTokenIndex), + ); + if (opensDeclarationBody) pendingDeclaration = undefined; + } else if (code[index] === "}" && openBraces.pop() === true) { + statementBlockCloseBraces.add(index); + } + + previousTokenIndex = index; + index++; + } + + return false; +} + +function startsDeclaration( + code: string, + previousTokenIndex: number, + controlConditionCloseParens: ReadonlySet, +): boolean { + if (previousTokenIndex < 0) return true; + if (";{}:".includes(code[previousTokenIndex] ?? "")) return true; + if (controlConditionCloseParens.has(previousTokenIndex)) return true; + return ["async", "default", "export"].includes( + identifierBefore(code, previousTokenIndex) ?? "", + ); +} + +function exportListExposesDefault(code: string, openBraceIndex: number): boolean { + const closeBraceIndex = findExportListCloseBrace(code, openBraceIndex); + if (closeBraceIndex === -1) return false; + + const specifiers = splitExportSpecifiers(code.slice(openBraceIndex + 1, closeBraceIndex)); + return specifiers.some((specifier) => exportedName(specifier) === "default"); +} + +function findExportListCloseBrace(code: string, openBraceIndex: number): number { + for (let index = openBraceIndex + 1; index < code.length;) { + const next = skipTextToken(code, index); + if (next !== index) { + index = next; + continue; + } + if (code[index] === "}") return index; + index++; + } + return -1; +} + +function splitExportSpecifiers(list: string): string[] { + const specifiers: string[] = []; + let start = 0; + + for (let index = 0; index < list.length;) { + const next = skipTextToken(list, index); + if (next !== index) { + index = next; + continue; + } + if (list[index] === ",") { + specifiers.push(list.slice(start, index)); + start = index + 1; + } + index++; + } + + specifiers.push(list.slice(start)); + return specifiers; +} + +function exportedName(specifier: string): string | undefined { + const tokens = identifierTokens(specifier); + if (tokens.length === 0) return undefined; + + for (let index = tokens.length - 2; index >= 0; index--) { + if (tokens[index] === "as") return tokens[index + 1]; + } + + return tokens.length === 1 ? tokens[0] : undefined; +} + +function identifierTokens(source: string): string[] { + const tokens: string[] = []; + + for (let index = 0; index < source.length;) { + const next = skipTextToken(source, index); + if (next !== index) { + index = next; + continue; + } + if (isIdentifierStart(source[index])) { + const start = index; + index++; + while (index < source.length && isIdentifierPart(source[index])) index++; + tokens.push(source.slice(start, index)); + continue; + } + index++; + } + + return tokens; +} + +function skipTrivia(source: string, index: number): number { + while (index < source.length) { + const char = source[index]; + if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f") { + index++; + continue; + } + + const next = skipComment(source, index); + if (next !== index) { + index = next; + continue; + } + + break; + } + return index; +} + +interface RegexScanContext { + previousTokenIndex: number; + controlConditionCloseParens: ReadonlySet; + statementBlockCloseBraces: ReadonlySet; +} + +function skipTextToken( + source: string, + index: number, + context?: RegexScanContext, +): number { + const commentEnd = skipComment(source, index); + if (commentEnd !== index) return commentEnd; + + const regexEnd = skipRegexToken(source, index, context); + if (regexEnd !== index) return regexEnd; + + const char = source[index]; + if (char !== '"' && char !== "'" && char !== "`") return index; + + for (index++; index < source.length; index++) { + if (source[index] === "\\") { + index++; + continue; + } + if (source[index] === char) return index + 1; + } + + return source.length; +} + +function skipRegexToken( + source: string, + index: number, + context?: RegexScanContext, +): number { + if (source[index] !== "/" || source[index + 1] === "/" || source[index + 1] === "*") { + return index; + } + + const previous = context?.previousTokenIndex ?? previousSignificantIndex(source, index); + if (previous >= 0) { + const char = source[previous]!; + if (char === ")" && context?.controlConditionCloseParens.has(previous)) { + // A statement can start with a regex immediately after a control + // condition, for example `if (ready) /pattern/.test(value)`. + } else if (char === "}" && context?.statementBlockCloseBraces.has(previous)) { + // The same is true after the braced form, for example + // `if (ready) {} /pattern/.test(value)` or + // `function ready() {} /pattern/.test(value)`. + } else if (!"([{=,:;!~?&|+-*%^<>".includes(char)) { + const keyword = identifierBefore(source, previous); + if (keyword !== null && isMemberNameBefore(source, previous)) return index; + if ( + ![ + "case", + "delete", + "do", + "else", + "extends", + "in", + "instanceof", + "new", + "await", + "return", + "throw", + "typeof", + "void", + "yield", + ].includes(keyword ?? "") + ) return index; + } + } + + let cursor = index + 1; + let inCharacterClass = false; + while (cursor < source.length) { + const char = source[cursor]!; + if (char === "\\") { + cursor += 2; + continue; + } + if (char === "[" && !inCharacterClass) { + inCharacterClass = true; + cursor++; + continue; + } + if (char === "]" && inCharacterClass) { + inCharacterClass = false; + cursor++; + continue; + } + if (char === "/" && !inCharacterClass) { + cursor++; + while (isIdentifierPart(source[cursor])) cursor++; + return cursor; + } + if (char === "\n" || char === "\r") return index; + cursor++; + } + + return index; +} + +function previousSignificantIndex(source: string, index: number): number { + let cursor = index - 1; + while (cursor >= 0 && /\s/.test(source[cursor] ?? "")) cursor--; + return cursor; +} + +function identifierBefore(source: string, endIndex: number): string | null { + const end = endIndex + 1; + let start = end; + while (start > 0 && isIdentifierPart(source[start - 1])) start--; + return start === end ? null : source.slice(start, end); +} + +function skipComment(source: string, index: number): number { + if (source[index] !== "/" || index + 1 >= source.length) return index; + if (source[index + 1] === "/") { + const newlineIndex = source.indexOf("\n", index + 2); + return newlineIndex === -1 ? source.length : newlineIndex + 1; + } + if (source[index + 1] === "*") { + const closeIndex = source.indexOf("*/", index + 2); + return closeIndex === -1 ? source.length : closeIndex + 2; + } + return index; +} + +function startsIdentifier(source: string, index: number, identifier: string): boolean { + if (source.slice(index, index + identifier.length) !== identifier) return false; + const before = index > 0 ? source[index - 1] : ""; + const after = source[index + identifier.length] ?? ""; + return !isIdentifierPart(before) && !isIdentifierPart(after); +} + +function isIdentifierStart(char: string | undefined): boolean { + if (char === undefined) return false; + return char === "$" || char === "_" || + (char >= "A" && char <= "Z") || + (char >= "a" && char <= "z"); +} + +function isIdentifierPart(char: string | undefined): boolean { + return isIdentifierStart(char) || (char !== undefined && char >= "0" && char <= "9"); } /** @@ -135,7 +526,15 @@ async function writeCycleTargetAlias( export async function persistTransformedModule( input: PersistTransformedModuleInput, ): Promise { - const transformedHash = hashCodeHex(input.transformedCode).slice(0, 8); + const unresolvedSpecifiers = [...new Set(input.unresolvedSpecifiers ?? [])].sort(); + const serializedUnresolvedSpecifiers = JSON.stringify(unresolvedSpecifiers); + // Evidence changes the artifact identity only when evidence exists. This + // keeps the common no-evidence path stable and prevents concurrent writers + // with different classification data from sharing one mutable sidecar. + const transformedIdentity = unresolvedSpecifiers.length === 0 + ? input.transformedCode + : `${input.transformedCode}\0${serializedUnresolvedSpecifiers}`; + const transformedHash = hashCodeHex(transformedIdentity).slice(0, 8); const relativePath = input.filePath.startsWith(input.projectDir) ? input.filePath.slice(input.projectDir.length).replace(/^\/+/, "") @@ -185,7 +584,26 @@ export async function persistTransformedModule( } } - if (input.contentSourceId) { + // Publish the path cache only after its tenant-attribution evidence is + // durable. A new worker can otherwise reuse the transformed artifact from + // _index.json without knowing which authored imports remained unresolved. + let shouldPublishReusableCache = true; + if (unresolvedSpecifiers.length > 0) { + try { + await input.localAdapter.fs.writeFile( + `${tempFilePath}${UNRESOLVED_IMPORTS_SIDECAR_SUFFIX}`, + serializedUnresolvedSpecifiers, + ); + } catch (error) { + shouldPublishReusableCache = false; + logger.warn("Failed to persist unresolved-import evidence", { + filePath: input.filePath.slice(-40), + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (shouldPublishReusableCache && input.contentSourceId) { const normalizedPath = `_vf_modules/${relativePath.replace(/\.(tsx?|jsx|mdx)$/, ".js")}`; const mdxCacheKey = buildMdxEsmPathCacheKey( normalizedPath, @@ -209,7 +627,9 @@ export async function persistTransformedModule( }); } - input.moduleCache.set(input.cacheKey, tempFilePath); + if (shouldPublishReusableCache) { + input.moduleCache.set(input.cacheKey, tempFilePath); + } if (input.isCycleTarget) { const hashedFileName = jsPath.slice(jsPath.lastIndexOf("/") + 1); diff --git a/src/rendering/orchestrator/pipeline.behavior.test.ts b/src/rendering/orchestrator/pipeline.behavior.test.ts index 3687660638..67e6b8726f 100644 --- a/src/rendering/orchestrator/pipeline.behavior.test.ts +++ b/src/rendering/orchestrator/pipeline.behavior.test.ts @@ -4,7 +4,8 @@ import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { FakeTime } from "#std/testing/time"; import { RenderPipeline, type RenderPipelineConfig } from "./pipeline.ts"; import type { RenderOptions } from "./types.ts"; -import { markBuildFailure } from "./module-loader/build-failure.ts"; +import { isTenantBuildFailure, markBuildFailure } from "./module-loader/build-failure.ts"; +import { COMPILATION_ERROR, createError, SSG_GENERATION_ERROR, toError } from "#veryfront/errors"; import { cachePageCss, getPageCssCacheKey } from "./css-cache.ts"; import { cacheCSSAsync, hashCSS } from "#veryfront/html/styles-builder/index.ts"; import { RELEASE_ASSET_MANIFEST_ENV_FLAG } from "#veryfront/release-assets/constants.ts"; @@ -700,12 +701,54 @@ describe("RenderPipeline behavior", () => { return context?.buildFailure; } - it("reports a build failure as one", async () => { + function tenantBuildFailureFlag(error: unknown): unknown { + const context = (error as { context?: { tenantBuildFailure?: unknown } }).context; + return context?.tenantBuildFailure; + } + + it("reports a source compilation failure as tenant-owned", async () => { const error = await rejectLoad(pipelineWithFailingPageModule(() => { - throw markBuildFailure(new Error("Cannot import the static asset")); + throw markBuildFailure(COMPILATION_ERROR.create({ + detail: "Cannot import the static asset", + context: { tenantBuildFailure: true }, + })); })); assertEquals(buildFailureFlag(error), true); + assertEquals(tenantBuildFailureFlag(error), true); + }); + + it("keeps generic compilation failures at framework severity", () => { + const infrastructureError = markBuildFailure(COMPILATION_ERROR.create({ + detail: "esbuild service exited unexpectedly", + })); + + assertEquals(isTenantBuildFailure(infrastructureError), false); + }); + + it("keeps framework failures inside the transform phase distinct", async () => { + const frameworkError = markBuildFailure(toError(createError({ + type: "build", + message: "cache write failed", + }))); + assertEquals(isTenantBuildFailure(frameworkError), false); + + const error = await rejectLoad(pipelineWithFailingPageModule(() => { + throw frameworkError; + })); + + assertEquals(buildFailureFlag(error), true); + assertEquals(tenantBuildFailureFlag(error), false); + }); + + it("does not infer tenant source from an SSG wrapper", () => { + const infrastructureError = markBuildFailure(SSG_GENERATION_ERROR.create({ + detail: "Failed to write generated page output", + cause: Object.assign(new Error("No space left on device"), { code: "ENOSPC" }), + context: { route: "/" }, + })); + + assertEquals(isTenantBuildFailure(infrastructureError), false); }); it("does not report a module-scope runtime throw as a build failure", async () => { @@ -714,6 +757,7 @@ describe("RenderPipeline behavior", () => { })); assertEquals(buildFailureFlag(error), false); + assertEquals(tenantBuildFailureFlag(error), false); }); }); diff --git a/src/rendering/orchestrator/pipeline.ts b/src/rendering/orchestrator/pipeline.ts index aef998165e..ab2fcf753d 100644 --- a/src/rendering/orchestrator/pipeline.ts +++ b/src/rendering/orchestrator/pipeline.ts @@ -69,7 +69,7 @@ import { } from "#veryfront/html/styles-builder/tailwind-compiler.ts"; import { getReadyManifestForRender } from "#veryfront/release-assets/manifest-cache.ts"; import { createEsmCache, createModuleCache, loadModule } from "./module-loader/index.ts"; -import { isBuildFailure } from "./module-loader/build-failure.ts"; +import { isBuildFailure, isTenantBuildFailure } from "./module-loader/build-failure.ts"; import type { ModuleLoaderConfig } from "./module-loader/index.ts"; import { getCSSImports, @@ -396,7 +396,12 @@ export class RenderPipeline { ); const loaded: LoadedModule[] = []; - const criticalFailures: Array<{ path: string; error: string; buildFailure: boolean }> = []; + const criticalFailures: Array<{ + path: string; + error: string; + buildFailure: boolean; + tenantBuildFailure: boolean; + }> = []; for (const result of results) { if (result.mod && !result.error) { @@ -413,6 +418,7 @@ export class RenderPipeline { path: result.path, error: errorMessage, buildFailure: isBuildFailure(result.error), + tenantBuildFailure: isTenantBuildFailure(result.error), }); renderPageLog.error("Critical page module failed to load", { path: result.path, @@ -439,6 +445,16 @@ export class RenderPipeline { // one that compiled and threw at module scope is an application // error the project's own error page should present. buildFailure: criticalFailures.some((f) => f.buildFailure), + // Only explicit compiler/source classifications may affect + // observability severity. Infrastructure can fail in the same phase. + // + // `every` rather than `some`: today `criticalFailures` holds at most + // one entry (collectModulesToLoad pushes exactly one `type: "page"`, + // and only pages reach here), so the two are equivalent. If that ever + // changes, one tenant mistake must not downgrade a framework fault + // that failed alongside it. The array is non-empty inside this branch, + // so `every` cannot vacuously return true. + tenantBuildFailure: criticalFailures.every((f) => f.tenantBuildFailure), loadedCount: loaded.length, totalModules: modules.length, }, diff --git a/src/server/handlers/dev/dashboard/api.test.ts b/src/server/handlers/dev/dashboard/api.test.ts index a9ea9abbc9..bc525b6e24 100644 --- a/src/server/handlers/dev/dashboard/api.test.ts +++ b/src/server/handlers/dev/dashboard/api.test.ts @@ -214,10 +214,10 @@ describe("Dashboard API - GET endpoints", () => { assertEquals("errors" in body, true); assertEquals("categories" in body, true); assertEquals("count" in body, true); - assertEquals(body.count, 65); + assertEquals(body.count, 66); assertEquals(body.categories, { config: 7, - build: 8, + build: 9, runtime: 7, route: 6, server: 8, diff --git a/src/transforms/esm/http-cache-helpers.ts b/src/transforms/esm/http-cache-helpers.ts index bc30a26433..be8919ed93 100644 --- a/src/transforms/esm/http-cache-helpers.ts +++ b/src/transforms/esm/http-cache-helpers.ts @@ -284,6 +284,7 @@ interface HttpCacheRequestIdentityContext { } const HTTP_CACHE_REQUEST_IDENTITY_CONTEXT = Symbol("http-cache-request-identity-context"); +const HTTP_MODULE_REQUEST_FINGERPRINT_NAMESPACE = "veryfront:http-module-request:v1"; type HttpCacheRequestIdentityCarrier = { [HTTP_CACHE_REQUEST_IDENTITY_CONTEXT]?: HttpCacheRequestIdentityContext; @@ -676,6 +677,11 @@ export function normalizeHttpUrl(raw: string): string { } } +/** Build an opaque identity for correlating one exact HTTP module request. */ +export function fingerprintHttpModuleRequest(rawUrl: string): Promise { + return computeHash(`${HTTP_MODULE_REQUEST_FINGERPRINT_NAMESPACE}\0${normalizeHttpUrl(rawUrl)}`); +} + export function resolveBareSpecifier( specifier: string, importMap: ImportMapConfig, diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index 0e6a90b34b..1ed5ab43b0 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -38,6 +38,7 @@ import { HTTP_MODULE_FETCH_TIMEOUT_MS } from "#veryfront/utils/constants/http.ts import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; import { VeryfrontError } from "#veryfront/errors"; import { MODULE_LOAD_TIMEOUT_MS } from "#veryfront/rendering/orchestrator/module-collection.ts"; +import { isTenantSourceBuildError } from "#veryfront/errors/tenant-classification.ts"; import { FakeTime } from "#std/testing/time"; import { __getMaxInFlightHttpFetchWaiterCountForTests, @@ -1240,6 +1241,107 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); }); + it("classifies an authored missing bare package without classifying direct HTTP failures", async () => { + const mockFetch = (() => + Promise.resolve(new Response("not found", { status: 404 }))) as typeof fetch; + + await withIsolatedHttpCache("vf-esm-missing-package-", mockFetch, async (tempDir) => { + const options = { cacheDir: tempDir, importMap: { imports: {}, scopes: {} } }; + const packageError = await assertRejects( + () => cacheHttpImportsToLocal('import "missing-tenant-package";', options), + Error, + ); + const explicitPackageError = await assertRejects( + () => cacheHttpImportsToLocal('import "npm:missing-tenant-package";', options), + Error, + ); + const directHttpError = await assertRejects( + () => cacheModuleToLocal("https://esm.sh/missing-framework-module", tempDir), + Error, + ); + + assertEquals(isTenantSourceBuildError(packageError), true); + assertEquals(isTenantSourceBuildError(explicitPackageError), true); + assertEquals(isTenantSourceBuildError(directHttpError), false); + }); + }); + + it("does not classify a missing dependency of an existing bare package as tenant source", async () => { + const packageUrl = "https://esm.sh/package-with-missing-dependency"; + const dependencyUrl = "https://esm.sh/missing-package-dependency.js"; + const mockFetch = ((input: string | URL | Request) => { + const url = String(input); + if (url.startsWith(packageUrl)) { + return Promise.resolve( + new Response(`import "${dependencyUrl}"; export const loaded = true;`, { + headers: { "content-type": "application/javascript" }, + }), + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + + for ( + const specifier of [ + "package-with-missing-dependency", + "npm:package-with-missing-dependency", + ] + ) { + await withIsolatedHttpCache( + "vf-esm-missing-package-dependency-", + mockFetch, + async (tempDir) => { + const error = await assertRejects( + () => + cacheHttpImportsToLocal(`import ${JSON.stringify(specifier)};`, { + cacheDir: tempDir, + importMap: { imports: {}, scopes: {} }, + }), + Error, + ); + + assertEquals(isTenantSourceBuildError(error), false, specifier); + }, + ); + } + }); + + it("distinguishes a package from a missing dependency at the same sanitized URL", async () => { + const packageUrl = "https://esm.sh/same-path-module.js?entry=root"; + const dependencyUrl = "https://esm.sh/same-path-module.js?entry=dependency"; + const mockFetch = ((input: string | URL | Request) => { + const url = String(input); + if (new URL(url).searchParams.get("entry") === "root") { + return Promise.resolve( + new Response(`import "${dependencyUrl}"; export const loaded = true;`, { + headers: { "content-type": "application/javascript" }, + }), + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + + await withIsolatedHttpCache( + "vf-esm-same-sanitized-package-url-", + mockFetch, + async (tempDir) => { + const error = await assertRejects( + () => + cacheHttpImportsToLocal('import "same-path-package";', { + cacheDir: tempDir, + importMap: { + imports: { "same-path-package": packageUrl }, + scopes: {}, + }, + }), + Error, + ); + + assertEquals(isTenantSourceBuildError(error), false); + }, + ); + }); + it("retries failures while reading an HTTP module body", async () => { let fetchCount = 0; diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index 27b7829c0e..643577fefc 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -53,6 +53,7 @@ import { describeHtmlModuleResponse, ensureAbsoluteDir, ensurePreparedHttpCacheRequestOptions, + fingerprintHttpModuleRequest, getEffectiveHttpCacheRequest, hashHttpCacheIdentity, hasIncompatibleFilePaths, @@ -181,10 +182,17 @@ interface HttpModuleFetchResult { contentType: string; } -function terminalHttpModuleFetchError(detail: string): VeryfrontError { +function terminalHttpModuleFetchError( + detail: string, + context: { + httpStatus?: number; + httpModuleUrl?: string; + httpModuleRequestFingerprint?: string; + } = {}, +): VeryfrontError { return BUILD_FAILED.create({ detail, - context: { phase: "http-module-fetch" }, + context: { phase: "http-module-fetch", ...context }, }); } @@ -316,7 +324,14 @@ async function fetchHttpModule( ); } catch (error) { if (error instanceof HttpModuleResponseError) { - throw terminalHttpModuleFetchError(`Failed to fetch ${safeUrl}: ${error.status}`); + throw terminalHttpModuleFetchError( + `Failed to fetch ${safeUrl}: ${error.status}`, + { + httpStatus: error.status, + httpModuleUrl: safeUrl, + httpModuleRequestFingerprint: await fingerprintHttpModuleRequest(url), + }, + ); } if (error instanceof HttpModuleRequestError) { throw terminalHttpModuleFetchError( diff --git a/src/transforms/esm/specifier-resolver.ts b/src/transforms/esm/specifier-resolver.ts index baf405d31a..787a68d9eb 100644 --- a/src/transforms/esm/specifier-resolver.ts +++ b/src/transforms/esm/specifier-resolver.ts @@ -8,6 +8,8 @@ */ import { basename } from "#veryfront/compat/path/index.ts"; +import { BUILD_FAILED } from "#veryfront/errors"; +import { snapshotVeryfrontError } from "#veryfront/errors/types.ts"; import { resolveImport } from "#veryfront/modules/import-map/resolver.ts"; import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; import { @@ -21,12 +23,15 @@ import { parseImports, replaceSpecifiers } from "./lexer.ts"; import { type CacheOptions, + fingerprintHttpModuleRequest, + getEffectiveHttpCacheRequest, isCanonicalReactEsmUrl, isExternalScheme, isHttpUrl, isInternalBare, isParentHttpModule, isRelative, + normalizeHttpUrl, resolveBareSpecifier, } from "./http-cache-helpers.ts"; @@ -47,6 +52,35 @@ function stringStartsWith(value: string, search: string): boolean { return ReflectApply(StringStartsWith, value, [search]) as boolean; } +function classifyAuthoredPackageFetchError( + error: unknown, + requestedPackageFingerprint: string | undefined, +): unknown { + const snapshot = snapshotVeryfrontError(error); + const context = snapshot?.context; + if ( + snapshot?.slug !== BUILD_FAILED.slug || + typeof context !== "object" || context === null || + typeof requestedPackageFingerprint !== "string" || + (context as { httpStatus?: unknown }).httpStatus !== 404 || + (context as { httpModuleRequestFingerprint?: unknown }).httpModuleRequestFingerprint !== + requestedPackageFingerprint + ) { + return error; + } + + return BUILD_FAILED.create({ + message: snapshot.message, + detail: snapshot.detail, + cause: error, + context: { + httpStatus: 404, + httpModuleRequestFingerprint: requestedPackageFingerprint, + tenantBuildFailure: true, + }, + }); +} + /** Function signature for caching an HTTP module and returning its local path. */ export type CacheHttpModuleFn = (url: string, options: CacheOptions) => Promise; @@ -164,7 +198,17 @@ async function resolveSpecifier( if (stringStartsWith(specifier, "npm:")) { const bareSpecifier = stringSlice(specifier, 4); - const cached = await cacheHttpModule(`https://esm.sh/${bareSpecifier}`, options); + const requestedPackageUrl = `https://esm.sh/${bareSpecifier}`; + let cached: string | null; + try { + cached = await cacheHttpModule(requestedPackageUrl, options); + } catch (error) { + const effective = getEffectiveHttpCacheRequest(requestedPackageUrl, options); + const requestedPackageFingerprint = await fingerprintHttpModuleRequest( + normalizeHttpUrl(effective.url), + ); + throw classifyAuthoredPackageFetchError(error, requestedPackageFingerprint); + } if (!cached) return bareSpecifier; if (isParentHttpModule(baseUrl)) { @@ -217,7 +261,21 @@ async function resolveSpecifier( if (mapped === specifier) return null; if (isLocalMappedSpecifier(mapped)) return mapped; - return resolveSpecifier(mapped, baseUrl, options, cacheHttpModule); + let requestedPackageUrl: string | undefined; + const cacheAuthoredPackage: CacheHttpModuleFn = async (url, cacheOptions) => { + const effective = getEffectiveHttpCacheRequest(url, cacheOptions); + requestedPackageUrl ??= normalizeHttpUrl(effective.url); + return await cacheHttpModule(url, cacheOptions); + }; + + try { + return await resolveSpecifier(mapped, baseUrl, options, cacheAuthoredPackage); + } catch (error) { + const requestedPackageFingerprint = requestedPackageUrl === undefined + ? undefined + : await fingerprintHttpModuleRequest(requestedPackageUrl); + throw classifyAuthoredPackageFetchError(error, requestedPackageFingerprint); + } } /** Complete specifier replacements for one module. */ diff --git a/src/transforms/import-rewriter/strategies/alias-strategy.ts b/src/transforms/import-rewriter/strategies/alias-strategy.ts index d6a6884737..9f7b714ae2 100644 --- a/src/transforms/import-rewriter/strategies/alias-strategy.ts +++ b/src/transforms/import-rewriter/strategies/alias-strategy.ts @@ -7,6 +7,16 @@ import type { import { appendDependencyPinningPathKey, normalizeExtension } from "../url-builder.ts"; import { getProjectRelativePath } from "../project-paths.ts"; +/** Rewrite a project alias through the canonical SSR module-path rule. */ +export function rewriteSsrProjectAliasSpecifier(specifier: string): string | null { + if (!specifier.startsWith("@/")) return null; + let normalizedPath = normalizeExtension(specifier.slice(2)); + if (!/\.(tsx?|jsx?|mjs|cjs|mdx|css)$/.test(normalizedPath)) { + normalizedPath = `${normalizedPath}.js`; + } + return `/_vf_modules/${normalizedPath}`; +} + export class AliasStrategy implements ImportRewriteStrategy { readonly name = "alias"; readonly priority = 1; @@ -20,15 +30,10 @@ export class AliasStrategy implements ImportRewriteStrategy { // SSR uses /_vf_modules/ paths for HTTP module resolution if (ctx.target === "ssr") { - let normalizedPath = normalizeExtension(path); - // Add .js if no extension present - if (!/\.(tsx?|jsx?|mjs|cjs|mdx|css)$/.test(normalizedPath)) { - normalizedPath = `${normalizedPath}.js`; - } // The SSR adapter adds `ssr`, routing, cache-buster, and dependency // snapshot params together after this strategy runs. Keeping this URL // query-free ensures its `.js` matcher still sees the edge. - return { specifier: `/_vf_modules/${normalizedPath}` }; + return { specifier: rewriteSsrProjectAliasSpecifier(info.specifier) ?? info.specifier }; } // Browser: Use /_vf_modules/ absolute paths when moduleServerUrl is configured. diff --git a/src/transforms/md/compiler/md-compiler.test.ts b/src/transforms/md/compiler/md-compiler.test.ts index 526e17f8b3..53c3256fad 100644 --- a/src/transforms/md/compiler/md-compiler.test.ts +++ b/src/transforms/md/compiler/md-compiler.test.ts @@ -1,9 +1,41 @@ import "#veryfront/schemas/_test-setup.ts"; import "../../mdx/compiler/__tests__/content-processor-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import { + register as registerContract, + tryResolve as tryResolveContract, + unregister as unregisterContract, +} from "#veryfront/extensions/contracts.ts"; +import type { ContentProcessor } from "#veryfront/extensions/content/index.ts"; +import { + createYamlParserProvider, + YamlParserProviderName, +} from "#veryfront/extensions/parser/yaml-parser.ts"; import { compileMarkdownRuntime } from "./md-compiler.ts"; +const markdownCompilationMode = "production"; + +async function withYamlSyntaxErrorProvider(body: () => Promise): Promise { + const previous = tryResolveContract(YamlParserProviderName); + registerContract( + YamlParserProviderName, + createYamlParserProvider(() => { + throw new SyntaxError("invalid YAML"); + }), + ); + try { + await body(); + } finally { + if (previous === undefined) { + unregisterContract(YamlParserProviderName); + } else { + registerContract(YamlParserProviderName, previous); + } + } +} + describe( "transforms/md/compiler/md-compiler", { sanitizeResources: false, sanitizeOps: false }, @@ -11,7 +43,7 @@ describe( describe("compileMarkdownRuntime", () => { it("compiles simple markdown to a React component", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Hello World\n\nSome paragraph text.", ); @@ -22,7 +54,7 @@ describe( it("returns frontmatter object", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "---\ntitle: Test\nauthor: Jane\n---\n# Content", ); @@ -31,23 +63,104 @@ describe( assertEquals(result.frontmatter.author, "Jane"); }); + it("classifies tenant Markdown frontmatter failures explicitly", async () => { + const error = await assertRejects( + () => + compileMarkdownRuntime( + markdownCompilationMode, + "/tmp/project", + "---\ntitle: [unterminated\n---\n# Content", + undefined, + "broken.md", + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "markdown-compile-error"); + assertEquals(error.category, "BUILD"); + }); + + it("classifies provider-independent Markdown frontmatter SyntaxError failures", async () => { + await withYamlSyntaxErrorProvider(async () => { + const error = await assertRejects( + () => + compileMarkdownRuntime( + markdownCompilationMode, + "/tmp/project", + "---\ntitle: broken\n---\n# Content", + undefined, + "provider-frontmatter.md", + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "markdown-compile-error"); + assertEquals(error.category, "BUILD"); + }); + }); + + it("preserves non-source processor failures", async () => { + const previous = tryResolveContract("ContentProcessor"); + registerContract( + "ContentProcessor", + { + compileMdx() { + throw new Error("not used"); + }, + compileMarkdown() { + throw new SyntaxError("YAML backend unavailable at line 1, column 1"); + }, + getRemarkPlugins() { + return []; + }, + getRehypePlugins() { + return []; + }, + } satisfies ContentProcessor, + ); + + try { + const error = await assertRejects(() => + compileMarkdownRuntime( + markdownCompilationMode, + "/tmp/project", + "# Content", + undefined, + "framework-failure.md", + ) + ); + + assertInstanceOf(error, Error); + assertEquals(error instanceof VeryfrontError, false); + assertEquals( + (error as Error).message, + "YAML backend unavailable at line 1, column 1", + ); + } finally { + registerContract("ContentProcessor", previous); + } + }); + it("extracts headings", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# First\n## Second\n### Third", ); assertEquals(Array.isArray(result.headings), true); - assertEquals(result.headings.length, 3); - assertEquals(result.headings[0]!.text, "First"); - assertEquals(result.headings[0]!.level, 1); - assertEquals(result.headings[1]!.text, "Second"); - assertEquals(result.headings[1]!.level, 2); + const headings = result.headings!; + assertEquals(headings.length, 3); + assertEquals(headings[0]!.text, "First"); + assertEquals(headings[0]!.level, 1); + assertEquals(headings[1]!.text, "Second"); + assertEquals(headings[1]!.level, 2); }); it("returns rawHtml", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Hello", ); @@ -57,7 +170,7 @@ describe( it("handles empty content", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "", ); @@ -67,7 +180,7 @@ describe( it("passes frontmatter through when provided as parameter", async () => { const fm = { title: "Override", custom: "value" }; const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Content", fm, @@ -83,7 +196,7 @@ describe( | Cell 1 | Cell 2 | `; const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", markdown, ); @@ -92,17 +205,18 @@ describe( it("generates heading IDs (slugs)", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Hello World", ); - assertEquals(result.headings[0]!.id, "hello-world"); + const headings = result.headings!; + assertEquals(headings[0]!.id, "hello-world"); }); it("compiles code blocks with syntax highlighting", async () => { const markdown = "```js\nconst x = 1;\n```"; const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", markdown, ); @@ -112,7 +226,7 @@ describe( it("uses preview wrapper for non-routable files", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Readme Content", undefined, @@ -123,7 +237,7 @@ describe( it("uses standard wrapper for pages/ files", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Page Content", undefined, @@ -136,7 +250,7 @@ describe( describe("HTML sanitization", () => { it("strips script tags from markdown", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", '# Title\n\n\n\nSafe text.', ); @@ -147,7 +261,7 @@ describe( it("strips onclick event handlers from HTML", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", '
Click me
', ); @@ -156,7 +270,7 @@ describe( it("strips iframe tags", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", '\n\nSafe text.', ); @@ -166,7 +280,7 @@ describe( it("strips javascript: URLs from links", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "[click me](javascript:alert(1))", ); @@ -175,7 +289,7 @@ describe( it("preserves safe HTML elements", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "**bold** and *italic* and [link](https://example.com)", ); @@ -186,7 +300,7 @@ describe( it("preserves images with safe src", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", '![alt text](https://example.com/img.png "title")', ); @@ -199,7 +313,7 @@ describe( it("preserves safe embedded HTML like details/summary", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "
Click\n\nHidden content\n\n
", ); @@ -210,7 +324,7 @@ describe( it("strips style tags", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Title\n\n\n\nVisible text.", ); @@ -220,7 +334,7 @@ describe( it("preserves data-node attributes in studio embed mode", async () => { const result = await compileMarkdownRuntime( - "runtime", + markdownCompilationMode, "/tmp/project", "# Hello\n\nSome paragraph.", undefined, diff --git a/src/transforms/md/compiler/md-compiler.ts b/src/transforms/md/compiler/md-compiler.ts index 0182d16e5b..eee48db84f 100644 --- a/src/transforms/md/compiler/md-compiler.ts +++ b/src/transforms/md/compiler/md-compiler.ts @@ -6,11 +6,16 @@ import type { ContentProcessingResult, ContentProcessor, } from "#veryfront/extensions/content/index.ts"; -import { createError, toError } from "#veryfront/errors"; +import { MARKDOWN_COMPILE_ERROR, VeryfrontError } from "#veryfront/errors"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { isFrontmatterSyntaxError } from "#veryfront/transforms/mdx/compiler/frontmatter-extractor.ts"; const logger = rendererLogger.component("md-compiler"); +function isMarkdownSourceCompileError(error: Error): boolean { + return isFrontmatterSyntaxError(error); +} + export function compileMarkdownRuntime( mode: CompilationMode, projectDir: string, @@ -45,12 +50,14 @@ export function compileMarkdownRuntime( stack: err.stack, }); - throw toError( - createError({ - type: "build", - message: `Markdown compilation error: ${err.message} | file: ${filePath ?? ""}`, - }), - ); + if (err instanceof VeryfrontError || !isMarkdownSourceCompileError(err)) { + throw err; + } + + throw MARKDOWN_COMPILE_ERROR.create({ + detail: `Markdown compilation error: ${err.message} | file: ${filePath ?? ""}`, + cause: err, + }); } }, { diff --git a/src/transforms/mdx/compiler/frontmatter-extractor.test.ts b/src/transforms/mdx/compiler/frontmatter-extractor.test.ts index 0d388705ec..d677022053 100644 --- a/src/transforms/mdx/compiler/frontmatter-extractor.test.ts +++ b/src/transforms/mdx/compiler/frontmatter-extractor.test.ts @@ -1,7 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { extractFrontmatter } from "./frontmatter-extractor.ts"; +import { extractFrontmatter, isFrontmatterSyntaxError } from "./frontmatter-extractor.ts"; + +const FRONTMATTER_SYNTAX_ERROR = Symbol.for("veryfront.transforms.mdx.frontmatter-syntax-error"); describe("transforms/mdx/compiler/frontmatter-extractor", () => { describe("extractFrontmatter", () => { @@ -99,5 +101,127 @@ export const title = "Override"; assertEquals(result.body, ""); assertEquals(result.frontmatter, {}); }); + + it("marks frontmatter syntax failures with an own data property", () => { + const error = assertThrows( + () => extractFrontmatter("---\ntitle: [unterminated\n---"), + SyntaxError, + ); + + assertEquals(isFrontmatterSyntaxError(error), true); + }); + + it("requires an own data marker without invoking accessors", () => { + const previous = Object.getOwnPropertyDescriptor( + SyntaxError.prototype, + FRONTMATTER_SYNTAX_ERROR, + ); + const previousDescriptorValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + let inheritedGetterRead = false; + let ownGetterRead = false; + + try { + Object.defineProperty(SyntaxError.prototype, FRONTMATTER_SYNTAX_ERROR, { + configurable: true, + value: true, + }); + assertEquals(isFrontmatterSyntaxError(new SyntaxError("framework failed")), false); + + Object.defineProperty(SyntaxError.prototype, FRONTMATTER_SYNTAX_ERROR, { + configurable: true, + get() { + inheritedGetterRead = true; + return true; + }, + }); + assertEquals(isFrontmatterSyntaxError(new SyntaxError("framework failed")), false); + + const accessorBacked = new SyntaxError("framework failed"); + Object.defineProperty(accessorBacked, FRONTMATTER_SYNTAX_ERROR, { + configurable: true, + get() { + ownGetterRead = true; + return true; + }, + }); + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: true, + }); + assertEquals(isFrontmatterSyntaxError(accessorBacked), false); + assertEquals(inheritedGetterRead, false); + assertEquals(ownGetterRead, false); + } finally { + if (previous) { + Object.defineProperty(SyntaxError.prototype, FRONTMATTER_SYNTAX_ERROR, previous); + } else { + delete (SyntaxError.prototype as { [FRONTMATTER_SYNTAX_ERROR]?: unknown })[ + FRONTMATTER_SYNTAX_ERROR + ]; + } + if (previousDescriptorValue) { + Object.defineProperty(Object.prototype, "value", previousDescriptorValue); + } else { + delete (Object.prototype as { value?: unknown }).value; + } + } + }); + + it("fails closed when a proxy throws during marker inspection", () => { + const hostileDescriptors = new Proxy(new SyntaxError("framework failed"), { + getOwnPropertyDescriptor() { + throw new Error("marker descriptor invoked proxy code"); + }, + }); + const hostilePrototype = new Proxy(new SyntaxError("framework failed"), { + getPrototypeOf() { + throw new Error("prototype inspection invoked proxy code"); + }, + }); + + assertEquals(isFrontmatterSyntaxError(hostileDescriptors), false); + assertEquals(isFrontmatterSyntaxError(hostilePrototype), false); + }); + + it("uses the descriptor intrinsic captured during module initialization", () => { + const previous = Object.getOwnPropertyDescriptor(Reflect, "getOwnPropertyDescriptor"); + if (!previous || typeof previous.value !== "function") { + throw new Error("Expected Reflect.getOwnPropertyDescriptor descriptor"); + } + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", { + ...previous, + value: () => ({ value: true }), + }); + + try { + assertEquals(isFrontmatterSyntaxError(new SyntaxError("framework failed")), false); + } finally { + Object.defineProperty(Reflect, "getOwnPropertyDescriptor", previous); + } + }); + + it("uses the definition intrinsic captured during module initialization", () => { + const defineProperty = Object.defineProperty; + const previous = Object.getOwnPropertyDescriptor(Object, "defineProperty"); + if (!previous || typeof previous.value !== "function") { + throw new Error("Expected Object.defineProperty descriptor"); + } + defineProperty(Object, "defineProperty", { + ...previous, + value: () => { + throw new Error("poisoned marker definition"); + }, + }); + + try { + const error = assertThrows( + () => extractFrontmatter("---\ntitle: [unterminated\n---"), + SyntaxError, + ); + assertEquals(isFrontmatterSyntaxError(error), true); + } finally { + defineProperty(Object, "defineProperty", previous); + } + }); }); }); diff --git a/src/transforms/mdx/compiler/frontmatter-extractor.ts b/src/transforms/mdx/compiler/frontmatter-extractor.ts index 031a687689..ef51542933 100644 --- a/src/transforms/mdx/compiler/frontmatter-extractor.ts +++ b/src/transforms/mdx/compiler/frontmatter-extractor.ts @@ -6,10 +6,41 @@ export interface FrontmatterExtractionResult { frontmatter: Record; } +const FRONTMATTER_SYNTAX_ERROR = Symbol.for("veryfront.transforms.mdx.frontmatter-syntax-error"); +const ObjectDefineProperty = Object.defineProperty; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; + +/** Return true when an error came from MDX or Markdown YAML frontmatter parsing. */ +export function isFrontmatterSyntaxError(error: unknown): error is SyntaxError { + try { + if (!(error instanceof SyntaxError)) return false; + const descriptor = ReflectGetOwnPropertyDescriptor(error, FRONTMATTER_SYNTAX_ERROR); + return descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true && + descriptor.value === true; + } catch { + return false; + } +} + +function createFrontmatterSyntaxError(cause: SyntaxError): SyntaxError { + const error = new SyntaxError(`Invalid YAML frontmatter: ${cause.message}`, { cause }); + ObjectDefineProperty(error, FRONTMATTER_SYNTAX_ERROR, { value: true }); + return error; +} + function extractYamlFrontmatter(content: string): FrontmatterExtractionResult { if (!content.trim().startsWith("---")) return { body: content, frontmatter: {} }; - const extracted = extract(content); + let extracted; + try { + extracted = extract(content); + } catch (error) { + if (error instanceof SyntaxError) throw createFrontmatterSyntaxError(error); + throw error; + } return { body: extracted.body, diff --git a/src/transforms/mdx/compiler/mdx-compiler.test.ts b/src/transforms/mdx/compiler/mdx-compiler.test.ts index 46a23095b0..b41b944783 100644 --- a/src/transforms/mdx/compiler/mdx-compiler.test.ts +++ b/src/transforms/mdx/compiler/mdx-compiler.test.ts @@ -1,7 +1,23 @@ import "#veryfront/schemas/_test-setup.ts"; import "./__tests__/content-processor-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertInstanceOf, + assertRejects, + assertStrictEquals, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import { + register as registerContract, + tryResolve as tryResolveContract, + unregister as unregisterContract, +} from "#veryfront/extensions/contracts.ts"; +import type { ContentProcessor } from "#veryfront/extensions/content/index.ts"; +import { + type YamlParserProvider, + YamlParserProviderName, +} from "#veryfront/extensions/parser/yaml-parser.ts"; import { compileMDXRuntime } from "./mdx-compiler.ts"; describe("transforms/mdx/compiler/mdx-compiler", () => { @@ -73,5 +89,284 @@ describe("transforms/mdx/compiler/mdx-compiler", () => { ); assertEquals(typeof result.compiledCode, "string"); }); + + it("classifies tenant MDX syntax failures explicitly", async () => { + const error = await assertRejects( + () => + compileMDXRuntime( + "production", + "/project", + " { + const error = await assertRejects( + () => + compileMDXRuntime( + "production", + "/project", + "---\ntitle: [unterminated\n---\n# Content", + undefined, + "broken-frontmatter.mdx", + "server", + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "mdx-compile-error"); + assertEquals(error.category, "BUILD"); + }); + + it("classifies frontmatter SyntaxErrors from compliant YAML providers", async () => { + const previous = tryResolveContract(YamlParserProviderName); + registerContract( + YamlParserProviderName, + { + parseYaml() { + throw new SyntaxError("invalid YAML"); + }, + } satisfies YamlParserProvider, + ); + + try { + const error = await assertRejects( + () => + compileMDXRuntime( + "production", + "/project", + "---\ntitle: broken\n---\n# Content", + undefined, + "broken-frontmatter.mdx", + "server", + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "mdx-compile-error"); + assertEquals(error.category, "BUILD"); + } finally { + if (previous) { + registerContract(YamlParserProviderName, previous); + } else { + unregisterContract(YamlParserProviderName); + } + } + }); + + it("preserves non-source processor failures", async () => { + const previous = tryResolveContract("ContentProcessor"); + registerContract( + "ContentProcessor", + { + compileMdx() { + throw new Error("Expected ContentProcessor to initialize"); + }, + compileMarkdown() { + throw new Error("not used"); + }, + getRemarkPlugins() { + return []; + }, + getRehypePlugins() { + return []; + }, + } satisfies ContentProcessor, + ); + + try { + const error = await assertRejects(() => + compileMDXRuntime( + "production", + "/project", + "# Hello", + undefined, + "framework-failure.mdx", + "server", + ) + ); + + assertInstanceOf(error, Error); + assertEquals(error instanceof VeryfrontError, false); + assertEquals((error as Error).message, "Expected ContentProcessor to initialize"); + } finally { + registerContract("ContentProcessor", previous); + } + }); + + it("preserves processor failures when MDX source fields are inherited", async () => { + const previous = tryResolveContract("ContentProcessor"); + const inheritedFields = { + source: "remark-mdx", + ruleId: "unexpected-token", + line: 1, + column: 1, + } as const; + const previousDescriptors = new Map( + Object.keys(inheritedFields).map((key) => [ + key, + Object.getOwnPropertyDescriptor(Error.prototype, key), + ]), + ); + const frameworkFailure = new Error("Expected ContentProcessor to initialize"); + for (const [key, value] of Object.entries(inheritedFields)) { + Object.defineProperty(Error.prototype, key, { configurable: true, value }); + } + registerContract( + "ContentProcessor", + { + compileMdx() { + throw frameworkFailure; + }, + compileMarkdown() { + throw new Error("not used"); + }, + getRemarkPlugins() { + return []; + }, + getRehypePlugins() { + return []; + }, + } satisfies ContentProcessor, + ); + + try { + const error = await assertRejects(() => + compileMDXRuntime( + "production", + "/project", + "# Hello", + undefined, + "framework-failure.mdx", + "server", + ) + ); + assertStrictEquals(error, frameworkFailure); + } finally { + registerContract("ContentProcessor", previous); + for (const [key, descriptor] of previousDescriptors) { + if (descriptor) Object.defineProperty(Error.prototype, key, descriptor); + else delete (Error.prototype as unknown as Record)[key]; + } + } + }); + + it("does not invoke accessor-backed MDX source fields", async () => { + const previous = tryResolveContract("ContentProcessor"); + const frameworkFailure = new Error("Expected ContentProcessor to initialize"); + let getterReads = 0; + for ( + const [key, value] of Object.entries({ + source: "remark-mdx", + ruleId: "unexpected-token", + line: 1, + column: 1, + }) + ) { + Object.defineProperty(frameworkFailure, key, { + configurable: true, + get() { + getterReads++; + return value; + }, + }); + } + registerContract( + "ContentProcessor", + { + compileMdx() { + throw frameworkFailure; + }, + compileMarkdown() { + throw new Error("not used"); + }, + getRemarkPlugins() { + return []; + }, + getRehypePlugins() { + return []; + }, + } satisfies ContentProcessor, + ); + + try { + const error = await assertRejects(() => + compileMDXRuntime( + "production", + "/project", + "# Hello", + undefined, + "framework-failure.mdx", + "server", + ) + ); + assertStrictEquals(error, frameworkFailure); + assertEquals(getterReads, 0); + } finally { + registerContract("ContentProcessor", previous); + } + }); + + it("preserves framework SyntaxErrors when the frontmatter prototype is polluted", async () => { + const marker = Symbol.for("veryfront.transforms.mdx.frontmatter-syntax-error"); + const previousMarker = Object.getOwnPropertyDescriptor(SyntaxError.prototype, marker); + const previousProcessor = tryResolveContract("ContentProcessor"); + const frameworkFailure = new SyntaxError("Expected ContentProcessor to initialize"); + Object.defineProperty(SyntaxError.prototype, marker, { + configurable: true, + value: true, + }); + registerContract( + "ContentProcessor", + { + compileMdx() { + throw frameworkFailure; + }, + compileMarkdown() { + throw new Error("not used"); + }, + getRemarkPlugins() { + return []; + }, + getRehypePlugins() { + return []; + }, + } satisfies ContentProcessor, + ); + + try { + const error = await assertRejects( + () => + compileMDXRuntime( + "production", + "/project", + "# Hello", + undefined, + "framework-failure.mdx", + "server", + ), + SyntaxError, + ); + assertStrictEquals(error, frameworkFailure); + } finally { + registerContract("ContentProcessor", previousProcessor); + if (previousMarker) { + Object.defineProperty(SyntaxError.prototype, marker, previousMarker); + } else { + delete (SyntaxError.prototype as { [marker]?: unknown })[marker]; + } + } + }); }); }); diff --git a/src/transforms/mdx/compiler/mdx-compiler.ts b/src/transforms/mdx/compiler/mdx-compiler.ts index d293e30004..11e6ee64af 100644 --- a/src/transforms/mdx/compiler/mdx-compiler.ts +++ b/src/transforms/mdx/compiler/mdx-compiler.ts @@ -6,10 +6,48 @@ import type { ContentProcessingResult, ContentProcessor, } from "#veryfront/extensions/content/index.ts"; -import { createError, toError } from "#veryfront/errors"; +import { MDX_COMPILE_ERROR, VeryfrontError } from "#veryfront/errors"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { isFrontmatterSyntaxError } from "./frontmatter-extractor.ts"; const logger = rendererLogger.component("mdx-compiler"); +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; + +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + try { + const descriptor = ReflectGetOwnPropertyDescriptor(value, key); + if ( + descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true + ) { + return descriptor.value; + } + } catch { + // A hostile proxy cannot provide trusted source-diagnostic evidence. + } + return undefined; +} + +function isMdxSourceCompileError(error: Error): boolean { + const source = readOwnDataProperty(error, "source"); + const ruleId = readOwnDataProperty(error, "ruleId"); + const line = readOwnDataProperty(error, "line"); + const column = readOwnDataProperty(error, "column"); + const isMdxParserError = typeof source === "string" && + /(?:^|-)mdx(?:-|$)|micromark|remark|recma|rehype/.test(source) && + typeof ruleId === "string" && + Number.isSafeInteger(line) && + Number.isSafeInteger(column); + // Frontmatter failures are identified by the symbol `extractFrontmatter` + // stamps at the throw site, not by matching stack-frame paths: `extract()` is + // the only frontmatter path and it tags every SyntaxError it raises. A + // stack-path heuristic would only add false positives (any SyntaxError whose + // stack happened to pass through the YAML shim) and does not survive + // `deno compile` anyway. + return isMdxParserError || isFrontmatterSyntaxError(error); +} export function compileMDXRuntime( mode: CompilationMode, @@ -37,20 +75,21 @@ export function compileMDXRuntime( studioEmbed, }); } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); logger.error("Compilation failed:", { filePath, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, + error: err.message, + stack: err.stack, }); - throw toError( - createError({ - type: "build", - message: `MDX compilation error: ${ - error instanceof Error ? error.message : String(error) - } | file: ${filePath ?? ""}`, - }), - ); + if (err instanceof VeryfrontError || !isMdxSourceCompileError(err)) { + throw err; + } + + throw MDX_COMPILE_ERROR.create({ + detail: `MDX compilation error: ${err.message} | file: ${filePath ?? ""}`, + cause: err, + }); } }, { diff --git a/src/transforms/mdx/esm-module-loader/cache-format.ts b/src/transforms/mdx/esm-module-loader/cache-format.ts index 2902bd18db..5c24775bd0 100644 --- a/src/transforms/mdx/esm-module-loader/cache-format.ts +++ b/src/transforms/mdx/esm-module-loader/cache-format.ts @@ -12,6 +12,8 @@ import { hashString } from "./utils/hash.ts"; const ALL_FILE_URL_PATTERN_SOURCE = /file:\/\/([^"'\s]+)/.source; const MJS_FILE_URL_PATTERN_SOURCE = /file:\/\/([^"'\s]+\.mjs)/.source; const CACHE_NAMESPACE_SENTINEL = "__vf_cache_namespace__"; +export const UNRESOLVED_IMPORTS_SIDECAR_SUFFIX = ".unresolved-imports.json"; +const MDX_ESM_PATH_CACHE_ATTRIBUTION_SCHEMA = "unresolved-import-sidecars-v1"; const PUBLIC_RUNTIME_SPECIFIERS = [ "veryfront/head", "veryfront/router", @@ -115,6 +117,7 @@ function buildMdxEsmCacheSchemaSample() { unresolvedVfModulesPattern: UNRESOLVED_VF_MODULES_PATTERN.source, allFileUrlPattern: ALL_FILE_URL_PATTERN_SOURCE, mjsFileUrlPattern: MJS_FILE_URL_PATTERN_SOURCE, + pathCacheAttributionSchema: MDX_ESM_PATH_CACHE_ATTRIBUTION_SCHEMA, sourceHashing: [ hashString("_vf_modules/pages/index.jsexport default 1;"), hashString("/tmp/project/Button.tsx\0export default function Button() {}"), diff --git a/src/transforms/mdx/esm-module-loader/cache/index.test.ts b/src/transforms/mdx/esm-module-loader/cache/index.test.ts index d4360cea6c..73c119910f 100644 --- a/src/transforms/mdx/esm-module-loader/cache/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/cache/index.test.ts @@ -23,7 +23,11 @@ import { exists, readTextFile, remove, writeTextFile } from "#veryfront/compat/f import { runWithCacheDir } from "#veryfront/utils/cache-dir.ts"; import { cacheModule } from "../module-fetcher/module-cache.ts"; import { rendererLogger as log } from "#veryfront/utils"; -import { buildMdxEsmModuleFileName, buildMdxEsmPathCacheKey } from "../cache-format.ts"; +import { + buildMdxEsmModuleFileName, + buildMdxEsmPathCacheKey, + UNRESOLVED_IMPORTS_SIDECAR_SUFFIX, +} from "../cache-format.ts"; import { getCacheStats } from "#veryfront/utils/memory/index.ts"; import { formatCacheVersionSegment } from "#veryfront/utils/cache-version.ts"; import { hashCodeHex } from "#veryfront/utils/hash-utils.ts"; @@ -451,6 +455,34 @@ describe("invalidateModulePaths — disk persistence", () => { } }); + it("deletes unresolved-import evidence beside stale modules", async () => { + clearModulePathCache(); + + const cacheDir = await makeTempDir({ prefix: "vf-mdx-invalidate-evidence-" }); + const versionedKey = buildMdxEsmPathCacheKey("_vf_modules/components/EmptyState.js"); + const staleMjsPath = join(cacheDir, buildMdxEsmModuleFileName("stale-evidence")); + const evidencePath = `${staleMjsPath}${UNRESOLVED_IMPORTS_SIDECAR_SUFFIX}`; + + try { + await writeTextFile(staleMjsPath, `export default "stale";`); + await writeTextFile(evidencePath, JSON.stringify(["./missing"])); + await writeTextFile( + join(cacheDir, "_index.json"), + JSON.stringify({ [versionedKey]: staleMjsPath }), + ); + await getModulePathCache(cacheDir); + + invalidateModulePaths(["components/EmptyState.tsx"]); + await waitForDiskCleanup(); + + assertEquals(await exists(staleMjsPath), false); + assertEquals(await exists(evidencePath), false); + } finally { + await remove(cacheDir, { recursive: true }).catch(() => {}); + clearModulePathCache(); + } + }); + it("cacheModule does not resurrect invalidated entries via disk content hash hit", async () => { clearModulePathCache(); diff --git a/src/transforms/mdx/esm-module-loader/cache/index.ts b/src/transforms/mdx/esm-module-loader/cache/index.ts index 680568e669..82412c56e7 100644 --- a/src/transforms/mdx/esm-module-loader/cache/index.ts +++ b/src/transforms/mdx/esm-module-loader/cache/index.ts @@ -20,7 +20,11 @@ import { LOG_PREFIX_MDX_LOADER } from "../constants.ts"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; import { registerCache } from "#veryfront/utils/memory/index.ts"; import { hashCodeHex } from "#veryfront/utils/hash-utils.ts"; -import { buildMdxEsmPathCacheKey, MDX_ESM_ALL_FILE_URL_PATTERN_SOURCE } from "../cache-format.ts"; +import { + buildMdxEsmPathCacheKey, + MDX_ESM_ALL_FILE_URL_PATTERN_SOURCE, + UNRESOLVED_IMPORTS_SIDECAR_SUFFIX, +} from "../cache-format.ts"; import { ensureMdxModuleDependencies } from "../module-fetcher/dependency-recovery.ts"; import { findStaticImportFromSpans } from "../utils/source-spans.ts"; import { @@ -352,7 +356,7 @@ export function invalidateModulePaths(changedPaths: string[]): void { } } - // Delete stale .mjs files from disk + // Delete stale modules and their tenant-attribution evidence from disk. for (const mjsPath of staleMjsFiles) { try { await localFs.remove(mjsPath); @@ -360,6 +364,11 @@ export function invalidateModulePaths(changedPaths: string[]): void { } catch (_) { /* expected: file may already be gone */ } + try { + await localFs.remove(`${mjsPath}${UNRESOLVED_IMPORTS_SIDECAR_SUFFIX}`); + } catch (_) { + /* expected: most modules have no unresolved-import evidence */ + } } }; _pendingDiskCleanup = _pendingDiskCleanup.then(cleanup, cleanup).catch((error) => { diff --git a/src/transforms/mdx/esm-module-loader/loader-helpers.ts b/src/transforms/mdx/esm-module-loader/loader-helpers.ts index 015a28008a..ca42f65bb8 100644 --- a/src/transforms/mdx/esm-module-loader/loader-helpers.ts +++ b/src/transforms/mdx/esm-module-loader/loader-helpers.ts @@ -17,14 +17,19 @@ import { getMdxEsmCacheDir } from "#veryfront/utils/cache-dir.ts"; import { exists as fsExists } from "#veryfront/platform/compat/fs.ts"; import { LOG_PREFIX_MDX_LOADER } from "./constants.ts"; import { getLocalFs } from "./cache/index.ts"; -import { createStubModule } from "./utils/stub-module.ts"; +import { createStubModule, type DeferredImportErrorDescriptor } from "./utils/stub-module.ts"; import { + findDynamicImportSpans, findStaticImportFromSpans, replaceSourceSpans, type SourceSpanReplacement, } from "./utils/source-spans.ts"; import { createModuleFetcherContext, fetchAndCacheModule } from "./module-fetcher/index.ts"; import { buildMissingModuleError } from "./missing-module.ts"; +import { + dynamicDependencyFailure, + toImportStringLiteral, +} from "./module-fetcher/nested-imports.ts"; import type { ESMLoaderContext } from "./types.ts"; import { parallelMap } from "#veryfront/utils/parallel.ts"; import { @@ -32,6 +37,7 @@ import { MAX_MDX_MODULE_IMPORTS_PER_FILE, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY, } from "./module-fetcher/limits.ts"; +import { splitSpecifierSuffix } from "#veryfront/transforms/shared/specifier-suffix.ts"; /** * Check which framework bundles are missing from disk. @@ -108,12 +114,33 @@ export async function initializeCacheDir(context: ESMLoaderContext): Promise { - return findStaticImportFromSpans( +): Array<{ + original: string; + path: string; + suffix: string; + start: number; + end: number; + isDynamic?: boolean; +}> { + const matchVfModule = (specifier: string): string | null => + specifier.match(/^\/?(_vf_modules\/.+)$/)?.[1] ?? null; + const staticImports = findStaticImportFromSpans( code, - (specifier) => specifier.match(/^\/?(_vf_modules\/[^?]+)(?:\?.*)?$/)?.[1], + matchVfModule, MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, ); + const dynamicImports = findDynamicImportSpans( + code, + matchVfModule, + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ).map((importSpan) => ({ ...importSpan, isDynamic: true })); + + return [...staticImports, ...dynamicImports] + .map((importSpan) => { + const { path, suffix } = splitSpecifierSuffix(importSpan.path); + return { ...importSpan, path, suffix }; + }) + .sort((left, right) => left.start - right.start); } /** @@ -121,7 +148,14 @@ export function findVfModuleImports( */ export async function processVfModuleImports( code: string, - imports: Array<{ original: string; path: string; start: number; end: number }>, + imports: Array<{ + original: string; + path: string; + suffix?: string; + start: number; + end: number; + isDynamic?: boolean; + }>, context: ESMLoaderContext, projectDir: string, strictMissingModules: boolean, @@ -180,7 +214,7 @@ export async function processVfModuleImports( const results = await parallelMap( imports, - async ({ original, path, start, end }, index) => { + async ({ original, path, suffix, start, end, isDynamic }, index) => { return await withSpan( SpanNames.MDX_FETCH_MODULE, async () => { @@ -190,14 +224,23 @@ export async function processVfModuleImports( index, path, }); - const filePath = await fetchAndCacheModule(path, fetcherContext); + let filePath: string | null; + let deferredError: DeferredImportErrorDescriptor | undefined; + try { + filePath = await fetchAndCacheModule(path, fetcherContext); + } catch (error) { + if (!isDynamic) throw error; + deferredError = dynamicDependencyFailure(path, error) ?? undefined; + if (!deferredError) throw error; + filePath = null; + } logger.debug(`${LOG_PREFIX_MDX_LOADER} Fetching module DONE`, { projectSlug, index, path, durationMs: (performance.now() - moduleStart).toFixed(1), }); - return { original, start, end, filePath, path }; + return { original, start, end, filePath, path, suffix, isDynamic, deferredError }; }, { "mdx.module_path": path, @@ -216,17 +259,39 @@ export async function processVfModuleImports( }); const replacements: SourceSpanReplacement[] = []; - for (const { original, start, end, filePath, path } of results) { + for ( + const { original, start, end, filePath, path, suffix, isDynamic, deferredError } of results + ) { if (filePath) { + const importTarget = toImportStringLiteral(`file://${filePath}${suffix ?? ""}`); replacements.push({ start, end, expected: original, - replacement: `from "file://${filePath}"`, + replacement: isDynamic ? importTarget : `from ${importTarget}`, }); continue; } + if (isDynamic) { + const deferredPath = await createStubModule( + path, + code, + original, + context.esmCacheDir!, + { failOnImport: strictMissingModules, deferredError }, + ); + if (deferredPath) { + replacements.push({ + start, + end, + expected: original, + replacement: toImportStringLiteral(`file://${deferredPath}${suffix ?? ""}`), + }); + continue; + } + } + if (strictMissingModules) { throw buildMissingModuleError({ modulePath: path, @@ -239,11 +304,12 @@ export async function processVfModuleImports( const stubPath = await createStubModule(path, code, original, context.esmCacheDir!); if (stubPath) { + const importTarget = toImportStringLiteral(`file://${stubPath}${suffix ?? ""}`); replacements.push({ start, end, expected: original, - replacement: `from "file://${stubPath}"`, + replacement: isDynamic ? importTarget : `from ${importTarget}`, }); } } diff --git a/src/transforms/mdx/esm-module-loader/missing-module.ts b/src/transforms/mdx/esm-module-loader/missing-module.ts index 0135d609ac..d23ecd4df2 100644 --- a/src/transforms/mdx/esm-module-loader/missing-module.ts +++ b/src/transforms/mdx/esm-module-loader/missing-module.ts @@ -55,3 +55,8 @@ export function buildMissingModuleError(ctx: MissingModuleContext): Error { return error; } + +/** Return true only for missing-module errors produced by this loader. */ +export function isMdxMissingModuleError(error: unknown): boolean { + return error instanceof Error && error.name === "MissingModuleError"; +} diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts index 29eba6d4f8..c437b2f1fb 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts @@ -48,7 +48,11 @@ export async function resolveUnresolvedModuleViaHttpFallback( input.projectSlug, input.isLocalProject, input.dependencyPinningCacheKey, - { moduleServerOrigin: input.moduleServerOrigin }, + { + esmCacheDir: input.esmCacheDir, + moduleServerOrigin: input.moduleServerOrigin, + strictMissingModules: input.strictMissingModules ?? true, + }, ); if (moduleCode) { diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts index ed46ef7f06..3c8377509d 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts @@ -6,6 +6,9 @@ import type { Logger } from "#veryfront/utils/logger/logger.ts"; import { fetchModuleViaHTTP } from "./http-fetcher.ts"; import { MAX_MDX_MODULE_CODE_BYTES, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY } from "./limits.ts"; import { HttpModuleBodyTooLargeError } from "../../../shared/http-module-response.ts"; +import { makeTempDir, remove } from "#veryfront/testing/deno-compat.ts"; +import { join, toFileUrl } from "#veryfront/compat/path/index.ts"; +import { buildMissingModuleError } from "../missing-module.ts"; describe("module-fetcher/http-fetcher", () => { it("falls back to bare localhost, carrying the project slug, when the subdomain will not resolve", async () => { @@ -187,6 +190,91 @@ describe("module-fetcher/http-fetcher", () => { ); }); + it("defers a missing dynamic import fetched through the HTTP fallback", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-http-dynamic-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./optional.js") : Promise.resolve("skipped");`; + + try { + const result = await fetchModuleViaHTTP( + "_vf_modules/pages/index.js", + { env: { get: () => undefined } } as unknown as RuntimeAdapter, + (path) => { + throw buildMissingModuleError({ + modulePath: path, + importer: "_vf_modules/pages/index.js", + importStatement: `import("./optional.js")`, + code: source, + projectSlug: "docs", + }); + }, + { debug: () => {}, warn: () => {} } as unknown as Logger, + "docs", + true, + undefined, + { + esmCacheDir, + fetchFn: (() => Promise.resolve(new Response(source))) as typeof fetch, + strictMissingModules: true, + }, + ); + const parentPath = join(esmCacheDir, "http-parent.mjs"); + await Deno.writeTextFile(parentPath, result!); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + await assertRejects( + () => loaded.load(true), + Error, + "Missing module: ./optional.js", + ); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("defers a typed dynamic child failure fetched through the HTTP fallback", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-http-dynamic-failure-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./oversized.js") : Promise.resolve("skipped");`; + + try { + const result = await fetchModuleViaHTTP( + "_vf_modules/pages/index.js", + { env: { get: () => undefined } } as unknown as RuntimeAdapter, + () => { + const error = new Error("private source detail"); + error.name = "ModuleSourceLimitError"; + throw error; + }, + { debug: () => {}, warn: () => {} } as unknown as Logger, + "docs", + true, + undefined, + { + esmCacheDir, + fetchFn: (() => Promise.resolve(new Response(source))) as typeof fetch, + strictMissingModules: true, + }, + ); + const parentPath = join(esmCacheDir, "http-typed-failure-parent.mjs"); + await Deno.writeTextFile(parentPath, result!); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + await assertRejects( + () => loaded.load(true), + Error, + "Dynamic import failed for ./oversized.js: module source exceeds the allowed size", + ); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); // A single-quoted specifier may legally contain a double quote, and a cache // path may contain a backslash. Interpolating either into a hand-written // double-quoted literal emits a module that fails to parse, which takes down diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index d202eed8de..24b0716d40 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -13,7 +13,11 @@ import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { LOG_PREFIX_MDX_LOADER } from "../constants.ts"; import { rewriteVeryfrontImports } from "./import-rewriter.ts"; -import { findNestedImports, toImportStringLiteral } from "./nested-imports.ts"; +import { + dynamicDependencyFailure, + findNestedImports, + toImportStringLiteral, +} from "./nested-imports.ts"; import { replaceSourceSpans, type SourceSpanReplacement } from "../utils/source-spans.ts"; import { HTTP_FETCH_TIMEOUT_MS } from "#veryfront/utils/constants/http.ts"; import { readHttpModuleText } from "../../../shared/http-module-response.ts"; @@ -22,10 +26,13 @@ import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/constants/limits.ts"; import { parallelMap } from "#veryfront/utils/parallel.ts"; import { Semaphore } from "#veryfront/modules/react-loader/ssr-module-loader/concurrency/semaphore.ts"; import { assertMdxModuleImportCount, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY } from "./limits.ts"; +import { createStubModule, type DeferredImportErrorDescriptor } from "../utils/stub-module.ts"; export interface FetchModuleViaHttpOptions { + esmCacheDir?: string; fetchFn?: typeof fetch; moduleServerOrigin?: string; + strictMissingModules?: boolean; timeoutMs?: number; } @@ -263,7 +270,27 @@ export async function fetchModuleViaHTTP( const results = await parallelMap( allImports, async ({ original, path, suffix, start, end, isDynamic, isSideEffect, key }) => { - const nestedFilePath = await fetchAndCacheModuleFn(path, normalizedPath); + let nestedFilePath: string | null; + let deferredError: DeferredImportErrorDescriptor | undefined; + try { + nestedFilePath = await fetchAndCacheModuleFn(path, normalizedPath); + } catch (error) { + deferredError = isDynamic + ? dynamicDependencyFailure(path, error) ?? undefined + : undefined; + if (!deferredError || !options.esmCacheDir) throw error; + nestedFilePath = null; + } + + if (!nestedFilePath && isDynamic && options.esmCacheDir) { + nestedFilePath = await createStubModule( + path, + moduleCode, + original, + options.esmCacheDir, + { failOnImport: options.strictMissingModules ?? true, deferredError }, + ); + } return { original, start, diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.test.ts index 1cfa657413..407c7463e4 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.test.ts @@ -2,6 +2,8 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { makeTempDir, remove } from "#veryfront/testing/deno-compat.ts"; +import { join, toFileUrl } from "#veryfront/compat/path/index.ts"; +import { COMPILATION_ERROR, MDX_COMPILE_ERROR } from "#veryfront/errors"; import { findNestedImports, hasUnresolvedImports, @@ -12,7 +14,9 @@ import { MAX_MDX_MODULE_IMPORTS_PER_FILE, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY, ModuleImportLimitError, + ModuleSourceLimitError, } from "./limits.ts"; +import { buildMissingModuleError } from "../missing-module.ts"; describe("transforms/mdx/esm-module-loader/module-fetcher/nested-imports", () => { describe("findNestedImports", () => { @@ -64,6 +68,31 @@ import { bar } from "./local.js"; "_vf_modules/polyfills/runtime.js", ]); }); + + it("does not abort on malformed escaped import specifiers", () => { + const code = [ + `import bad from "./bad\\xZZ";`, + `import good from "/_vf_modules/components/Good.js";`, + ].join("\n"); + + const result = findNestedImports(code); + + assertEquals(result.vfModules.map((module) => module.path), []); + assertEquals(result.relative.map((module) => module.path), []); + }); + + it("does not resolve import-looking regex text after an export list", () => { + const code = [ + `const value = 1;`, + `export { value }`, + `/import("\\.\\/optional.js")/.test(input);`, + ].join("\n"); + + const result = findNestedImports(code); + + assertEquals(result.vfModules, []); + assertEquals(result.relative, []); + }); }); describe("hasUnresolvedImports", () => { @@ -141,6 +170,20 @@ import { bar } from "./local.js"; assertEquals(result.count, 0); assertEquals(result.paths, []); }); + + it("treats malformed escaped import specifiers as unresolved evidence", () => { + const result = hasUnresolvedImports(`import bad from "/_vf_modules/bad\\xZZ";`); + + assertEquals(result.count, 1); + assertEquals(result.paths, [""]); + }); + + it("treats raw line terminators in import specifiers as unresolved evidence", () => { + const result = hasUnresolvedImports('import bad from "/_vf_modules/bad\nmodule.js";'); + + assertEquals(result.count, 1); + assertEquals(result.paths, [""]); + }); }); describe("resolveNestedModuleImports", () => { @@ -362,6 +405,356 @@ import { bar } from "./local.js"; } }); + it("defers a missing strict dynamic import until the branch executes", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-dynamic-missing-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./optional.js") : Promise.resolve("skipped");`; + const calls: string[] = []; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: (path) => { + calls.push(path); + throw buildMissingModuleError({ + modulePath: path, + importer: "_vf_modules/pages/index.js", + importStatement: `import("./optional.js")`, + code: source, + projectSlug: "docs", + }); + }, + }); + const parentPath = join(esmCacheDir, "dynamic-parent.mjs"); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(calls, ["./optional.js"]); + assertEquals(await loaded.load(false), "skipped"); + await assertRejects( + () => loaded.load(true), + Error, + "Missing module: ./optional.js", + ); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("defers strict dynamic child source failures until the branch executes", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-dynamic-source-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./oversized.js") : Promise.resolve("skipped");`; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: (path) => { + throw new ModuleSourceLimitError(path, 2048, 1024); + }, + }); + const parentPath = join(esmCacheDir, "dynamic-source-parent.mjs"); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + const error = await assertRejects( + () => loaded.load(true), + Error, + "module source exceeds the allowed size", + ); + if (!(error instanceof Error)) throw new Error("expected Error"); + assertEquals(error.name, "ModuleSourceLimitError"); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("defers strict dynamic child transform failures with sanitized runtime errors", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-dynamic-transform-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./broken.mdx") : Promise.resolve("skipped");`; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + throw MDX_COMPILE_ERROR.create({ + detail: "MDX compilation error: | file: /broken.mdx", + }); + }, + }); + const parentPath = join(esmCacheDir, "dynamic-transform-parent.mjs"); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + const error = await assertRejects( + () => loaded.load(true), + Error, + "MDX compilation failed", + ); + if (!(error instanceof Error)) throw new Error("expected Error"); + assertEquals(error.name, "MdxCompileError"); + assertEquals(error.message.includes(""), false); + assertEquals(error.message.includes(""), false); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("defers tenant TypeScript compilation failures until the branch executes", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-dynamic-ts-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./broken.ts") : Promise.resolve("skipped");`; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + throw COMPILATION_ERROR.create({ + detail: "ESM transform failed for /broken.ts: ", + context: { tenantBuildFailure: true }, + }); + }, + }); + const parentPath = join(esmCacheDir, "dynamic-ts-parent.mjs"); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + const error = await assertRejects( + () => loaded.load(true), + Error, + "TypeScript compilation failed", + ); + if (!(error instanceof Error)) throw new Error("expected Error"); + assertEquals(error.name, "CompilationError"); + assertEquals(error.message.includes(""), false); + assertEquals(error.message.includes(""), false); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("defers strict dynamic child cycles until the branch executes", async () => { + const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-dynamic-cycle-cache-" }); + const source = + `export const load = (enabled) => enabled ? import("./cycle.js") : Promise.resolve("skipped");`; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + const error = new Error( + "Circular module dependency detected: _vf_modules/pages/index.js -> ./cycle.js", + ); + error.name = "CircularModuleDependencyError"; + throw error; + }, + }); + const parentPath = join(esmCacheDir, "dynamic-cycle-parent.mjs"); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + const error = await assertRejects( + () => loaded.load(true), + Error, + "circular module dependency", + ); + if (!(error instanceof Error)) throw new Error("expected Error"); + assertEquals(error.name, "CircularModuleDependencyError"); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + }); + + it("uses fallback stubs for non-strict dynamic child dependency failures", async () => { + const cases = [ + { + name: "source", + path: "./oversized.js", + error: () => new ModuleSourceLimitError("./oversized.js", 2048, 1024), + }, + { + name: "transform", + path: "./broken.mdx", + error: () => + MDX_COMPILE_ERROR.create({ + detail: "MDX compilation error: | file: /broken.mdx", + }), + }, + { + name: "cycle", + path: "./cycle.js", + error: () => { + const error = new Error( + "Circular module dependency detected: _vf_modules/pages/index.js -> ./cycle.js", + ); + error.name = "CircularModuleDependencyError"; + return error; + }, + }, + ]; + + for (const testCase of cases) { + const esmCacheDir = await makeTempDir({ + prefix: `vf-mdx-dynamic-${testCase.name}-fallback-cache-`, + }); + const source = + `export const load = (enabled) => enabled ? import("${testCase.path}") : Promise.resolve("skipped");`; + + try { + const result = await resolveNestedModuleImports({ + moduleCode: source, + esmCacheDir, + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: false, + fetchAndCacheModule: () => { + throw testCase.error(); + }, + }); + const parentPath = join(esmCacheDir, `dynamic-${testCase.name}-fallback-parent.mjs`); + await Deno.writeTextFile(parentPath, result); + const loaded = await import( + `${toFileUrl(parentPath).href}?test=${crypto.randomUUID()}` + ) as { load(enabled: boolean): Promise }; + + assertEquals(await loaded.load(false), "skipped"); + const loadedFallback = await loaded.load(true); + assertEquals(typeof loadedFallback, "object"); + } finally { + await remove(esmCacheDir, { recursive: true }); + } + } + }); + + it("keeps strict static imports fail-fast", async () => { + await assertRejects( + () => + resolveNestedModuleImports({ + moduleCode: `import value from "./missing.js"; export { value };`, + esmCacheDir: "/tmp/veryfront-unused", + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => Promise.resolve(null), + }), + Error, + "Missing module: ./missing.js", + ); + }); + + it("keeps strict static child source failures fail-fast", async () => { + await assertRejects( + () => + resolveNestedModuleImports({ + moduleCode: `import value from "./oversized.js"; export { value };`, + esmCacheDir: "/tmp/veryfront-unused", + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: (path) => { + throw new ModuleSourceLimitError(path, 2048, 1024); + }, + }), + ModuleSourceLimitError, + "exceeds the source-size limit", + ); + }); + + it("keeps strict static child transform failures fail-fast", async () => { + await assertRejects( + () => + resolveNestedModuleImports({ + moduleCode: `import value from "./broken.mdx"; export { value };`, + esmCacheDir: "/tmp/veryfront-unused", + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + throw MDX_COMPILE_ERROR.create({ + detail: "MDX compilation error: | file: /broken.mdx", + }); + }, + }), + Error, + "MDX compilation error", + ); + }); + + it("keeps strict static child cycles fail-fast", async () => { + await assertRejects( + () => + resolveNestedModuleImports({ + moduleCode: `import value from "./cycle.js"; export { value };`, + esmCacheDir: "/tmp/veryfront-unused", + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + const error = new Error( + "Circular module dependency detected: _vf_modules/pages/index.js -> ./cycle.js", + ); + error.name = "CircularModuleDependencyError"; + throw error; + }, + }), + Error, + "Circular module dependency detected", + ); + }); + + it("keeps dynamic infrastructure failures fail-fast", async () => { + await assertRejects( + () => + resolveNestedModuleImports({ + moduleCode: `export const load = () => import("./later.js");`, + esmCacheDir: "/tmp/veryfront-unused", + normalizedPath: "_vf_modules/pages/index.js", + projectSlug: "docs", + strictMissingModules: true, + fetchAndCacheModule: () => { + throw new Error("cache backend unavailable"); + }, + }), + Error, + "cache backend unavailable", + ); + }); + it("resolves admitted fan-out with bounded concurrency", async () => { const importCount = MAX_MDX_MODULE_TRANSFORM_CONCURRENCY + 4; const moduleCode = Array.from( diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.ts index 323d69fd96..df35ba1d0d 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/nested-imports.ts @@ -7,15 +7,17 @@ import { splitSpecifierSuffix } from "#veryfront/transforms/shared/specifier-suffix.ts"; import { LOG_PREFIX_MDX_LOADER } from "../constants.ts"; import type { NestedImportResult } from "../types.ts"; -import { createStubModule } from "../utils/stub-module.ts"; +import { createStubModule, type DeferredImportErrorDescriptor } from "../utils/stub-module.ts"; import { findDynamicImportSpans, findStaticImportFromSpans, findStaticSideEffectImportSpans, replaceSourceSpans, type SourceSpanReplacement, + type StaticImportSpan, } from "../utils/source-spans.ts"; import { buildMissingModuleError } from "../missing-module.ts"; +import { isMdxMissingModuleError } from "../missing-module.ts"; import type { Logger } from "#veryfront/utils"; import { parallelMap } from "#veryfront/utils/parallel.ts"; import { Semaphore } from "#veryfront/modules/react-loader/ssr-module-loader/concurrency/semaphore.ts"; @@ -24,6 +26,8 @@ import { MAX_MDX_MODULE_IMPORTS_PER_FILE, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY, } from "./limits.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import { isTenantSourceBuildError } from "#veryfront/errors/tenant-classification.ts"; function matchUnresolvedVfModuleSpecifier(specifier: string): string | null { return specifier.match(/^((?:file:\/\/)?\/?\/?_vf_modules\/.+)$/)?.[1] ?? null; @@ -39,6 +43,77 @@ type NestedImportSpan = { isSideEffect?: boolean; }; +const MALFORMED_IMPORT_SPECIFIER = ""; + +function isMalformedSpecifierSyntaxError(error: unknown): boolean { + return error instanceof SyntaxError && error.message.includes("module specifier"); +} + +/** Return the sanitized runtime failure for a dependency error that can stay lazy. */ +export function dynamicDependencyFailure( + modulePath: string, + error: unknown, +): DeferredImportErrorDescriptor | null { + if (!(error instanceof Error)) return null; + + if (isMdxMissingModuleError(error)) { + return { + name: "MissingModuleError", + message: + `[Veryfront] Missing module: ${modulePath}. This module or file does not exist in your project.`, + }; + } + + if (error.name === "CircularModuleDependencyError") { + return { + name: "CircularModuleDependencyError", + message: `[Veryfront] Dynamic import failed for ${modulePath}: circular module dependency.`, + }; + } + + if ( + error.name === "ModuleSourceLimitError" || + error.name === "HttpModuleBodyTooLargeError" + ) { + return { + name: "ModuleSourceLimitError", + message: + `[Veryfront] Dynamic import failed for ${modulePath}: module source exceeds the allowed size.`, + }; + } + + if (error instanceof VeryfrontError && error.slug === "mdx-compile-error") { + return { + name: "MdxCompileError", + message: `[Veryfront] Dynamic import failed for ${modulePath}: MDX compilation failed.`, + }; + } + + if ( + error instanceof VeryfrontError && error.slug === "compilation-error" && + isTenantSourceBuildError(error) + ) { + return { + name: "CompilationError", + message: + `[Veryfront] Dynamic import failed for ${modulePath}: TypeScript compilation failed.`, + }; + } + + return null; +} + +function scanImportSpans( + scan: () => StaticImportSpan[], +): { spans: StaticImportSpan[]; malformed: boolean } { + try { + return { spans: scan(), malformed: false }; + } catch (error) { + if (!isMalformedSpecifierSyntaxError(error)) throw error; + return { spans: [], malformed: true }; + } +} + /** * Serialize a resolved module URL as a JavaScript string literal. * @@ -63,13 +138,51 @@ export function findNestedImports( } { const vfModules: NestedImportSpan[] = []; const relative: NestedImportSpan[] = []; - - for ( - const { original, path: rawPath, start, end } of findStaticImportFromSpans( + const staticVfModuleSpans = scanImportSpans(() => + findStaticImportFromSpans( + moduleCode, + matchUnresolvedVfModuleSpecifier, + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ) + ).spans; + const dynamicVfModuleSpans = scanImportSpans(() => + findDynamicImportSpans( moduleCode, matchUnresolvedVfModuleSpecifier, MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, ) + ).spans; + const sideEffectVfModuleSpans = scanImportSpans(() => + findStaticSideEffectImportSpans( + moduleCode, + matchUnresolvedVfModuleSpecifier, + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ) + ).spans; + const staticRelativeSpans = scanImportSpans(() => + findStaticImportFromSpans( + moduleCode, + (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ) + ).spans; + const dynamicRelativeSpans = scanImportSpans(() => + findDynamicImportSpans( + moduleCode, + (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ) + ).spans; + const sideEffectRelativeSpans = scanImportSpans(() => + findStaticSideEffectImportSpans( + moduleCode, + (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], + MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, + ) + ).spans; + + for ( + const { original, path: rawPath, start, end } of staticVfModuleSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath.replace(/^(?:file:\/\/)?\/+/, "")); // Strip file:// prefix and leading slashes to get clean _vf_modules/... path @@ -83,11 +196,7 @@ export function findNestedImports( } for ( - const { original, path: rawPath, start, end } of findDynamicImportSpans( - moduleCode, - matchUnresolvedVfModuleSpecifier, - MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ) + const { original, path: rawPath, start, end } of dynamicVfModuleSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath.replace(/^(?:file:\/\/)?\/+/, "")); // Strip file:// prefix and leading slashes to get clean _vf_modules/... path @@ -102,11 +211,7 @@ export function findNestedImports( } for ( - const { original, path: rawPath, start, end } of findStaticSideEffectImportSpans( - moduleCode, - matchUnresolvedVfModuleSpecifier, - MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ) + const { original, path: rawPath, start, end } of sideEffectVfModuleSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath.replace(/^(?:file:\/\/)?\/+/, "")); // Strip file:// prefix and leading slashes to get clean _vf_modules/... path @@ -121,11 +226,7 @@ export function findNestedImports( } for ( - const { original, path: rawPath, start, end } of findStaticImportFromSpans( - moduleCode, - (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], - MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ) + const { original, path: rawPath, start, end } of staticRelativeSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath); relative.push({ @@ -138,11 +239,7 @@ export function findNestedImports( } for ( - const { original, path: rawPath, start, end } of findDynamicImportSpans( - moduleCode, - (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], - MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ) + const { original, path: rawPath, start, end } of dynamicRelativeSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath); relative.push({ @@ -156,11 +253,7 @@ export function findNestedImports( } for ( - const { original, path: rawPath, start, end } of findStaticSideEffectImportSpans( - moduleCode, - (specifier) => specifier.match(/^(\.\.?\/.+)$/)?.[1], - MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ) + const { original, path: rawPath, start, end } of sideEffectRelativeSpans ) { const { path, suffix } = splitSpecifierSuffix(rawPath); relative.push({ @@ -180,26 +273,40 @@ export function findNestedImports( * Check for unresolved /_vf_modules/ imports. */ export function hasUnresolvedImports(moduleCode: string): { count: number; paths: string[] } { - const matches = [ - ...findStaticImportFromSpans( + const staticMatches = scanImportSpans(() => + findStaticImportFromSpans( moduleCode, matchUnresolvedVfModuleSpecifier, MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ), - ...findStaticSideEffectImportSpans( + ) + ); + const sideEffectMatches = scanImportSpans(() => + findStaticSideEffectImportSpans( moduleCode, matchUnresolvedVfModuleSpecifier, MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ), - ...findDynamicImportSpans( + ) + ); + const dynamicMatches = scanImportSpans(() => + findDynamicImportSpans( moduleCode, matchUnresolvedVfModuleSpecifier, MAX_MDX_MODULE_IMPORTS_PER_FILE + 1, - ), + ) + ); + const matches = [ + ...staticMatches.spans, + ...sideEffectMatches.spans, + ...dynamicMatches.spans, ]; + const malformedCount = [staticMatches, sideEffectMatches, dynamicMatches] + .filter((result) => result.malformed).length; return { - count: matches.length, - paths: matches.map((match) => match.path).slice(0, 5), + count: matches.length + malformedCount, + paths: [ + ...matches.map((match) => match.path), + ...Array.from({ length: malformedCount }, () => MALFORMED_IMPORT_SPECIFIER), + ].slice(0, 5), }; } @@ -225,6 +332,7 @@ export async function processNestedImports( isDynamic, isSideEffect, nestedFilePath, + deferredError, nestedPath, relativePath, } of results @@ -245,6 +353,25 @@ export async function processNestedImports( } const modulePath = nestedPath || relativePath || ""; + if (isDynamic) { + const deferredPath = await createStubModule( + modulePath, + moduleCode, + original, + esmCacheDir, + { failOnImport: strictMissingModules, deferredError }, + ); + if (deferredPath) { + replacements.push({ + start, + end, + expected: original, + replacement: toImportStringLiteral(`file://${deferredPath}${suffix ?? ""}`), + }); + continue; + } + } + if (strictMissingModules) { throw buildMissingModuleError({ modulePath, @@ -364,19 +491,41 @@ export async function resolveNestedModuleImports( const nestedResults: NestedImportResult[] = await parallelMap( allImports, - async ({ original, path, suffix, start, end, isDynamic, isSideEffect, key }) => ({ - original, - start, - end, - suffix, - isDynamic, - isSideEffect, - nestedFilePath: await input.fetchAndCacheModule( - path, - input.parentBasePath ?? input.normalizedPath, - ), - [key]: path, - }), + async ({ original, path, suffix, start, end, isDynamic, isSideEffect, key }) => { + let nestedFilePath: string | null; + try { + nestedFilePath = await input.fetchAndCacheModule( + path, + input.parentBasePath ?? input.normalizedPath, + ); + } catch (error) { + const deferredError = isDynamic ? dynamicDependencyFailure(path, error) : null; + if (!deferredError) throw error; + nestedFilePath = null; + return { + original, + start, + end, + suffix, + isDynamic, + isSideEffect, + nestedFilePath, + deferredError, + [key]: path, + }; + } + + return { + original, + start, + end, + suffix, + isDynamic, + isSideEffect, + nestedFilePath, + [key]: path, + }; + }, { semaphore: new Semaphore(MAX_MDX_MODULE_TRANSFORM_CONCURRENCY), }, diff --git a/src/transforms/mdx/esm-module-loader/module-writer.test.ts b/src/transforms/mdx/esm-module-loader/module-writer.test.ts index 1f01141dc1..b625bf43d9 100644 --- a/src/transforms/mdx/esm-module-loader/module-writer.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-writer.test.ts @@ -12,6 +12,7 @@ import type { FileSystem } from "#veryfront/platform/compat/fs.ts"; import { VeryfrontError } from "#veryfront/errors"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; import type { MDXModule } from "../types.ts"; +import { MAX_MDX_MODULE_CODE_BYTES } from "./module-fetcher/limits.ts"; function cacheKeyForDependencies( dependencies: Readonly>, @@ -164,6 +165,159 @@ describe("MDX root module cache identity", () => { }); }); +describe("MDX root dynamic imports", () => { + for ( + const { label, suffix } of [ + { label: "query", suffix: "?raw" }, + { label: "fragment", suffix: "#variant" }, + { label: "query and fragment", suffix: "?raw#variant" }, + ] as const + ) { + it(`preserves a root dynamic import ${label} suffix`, async () => { + const moduleName = `RootSuffix-${label.replaceAll(" ", "-")}-${crypto.randomUUID()}.js`; + const expectedPath = `/_vf_modules/${moduleName}`; + const projectDir = await Deno.makeTempDir({ prefix: "vf-mdx-root-dynamic-suffix-" }); + const fetchedPaths: string[] = []; + + try { + const mod = await withMockFetch( + (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + fetchedPaths.push(url.pathname); + if (url.pathname !== expectedPath || url.searchParams.get("ssr") !== "true") { + return Promise.resolve(new Response("missing", { status: 404 })); + } + return Promise.resolve( + new Response("export default import.meta.url;", { + headers: { "content-type": "application/javascript" }, + }), + ); + }, + () => + mdxRenderer.loadModuleESM( + `export async function loadVariant() { + return (await import("${expectedPath}${suffix}")).default; + } + export default function Root() { return null; }`, + { + adapter: denoAdapter, + projectId: `project-${crypto.randomUUID()}`, + projectDir, + projectSlug: "root-dynamic-suffix", + contentSourceId: `source-${crypto.randomUUID()}`, + isLocalProject: true, + }, + ), + ); + const loadVariant = (mod as unknown as { + loadVariant(): Promise; + }).loadVariant; + + const importedUrl = await loadVariant(); + assertEquals(fetchedPaths, [expectedPath]); + assertEquals(importedUrl.endsWith(suffix), true); + } finally { + mdxRenderer.clearCache(); + await Deno.remove(projectDir, { recursive: true }); + const esbuild = await import("veryfront/extensions/bundler"); + await esbuild.stop(); + } + }); + } + + it("defers a missing strict alias import until its branch executes", async () => { + const missingModule = `MissingRoot-${crypto.randomUUID()}`; + const projectDir = await Deno.makeTempDir({ prefix: "vf-mdx-root-dynamic-" }); + + try { + const mod = await withMockFetch( + () => Promise.resolve(new Response("missing", { status: 404 })), + () => + mdxRenderer.loadModuleESM( + `export async function loadOptional(enabled) { + if (!enabled) return "SKIPPED"; + return (await import("@/${missingModule}")).default; + } + export default function Root() { return null; }`, + { + adapter: denoAdapter, + projectId: `project-${crypto.randomUUID()}`, + projectDir, + projectSlug: "root-dynamic", + contentSourceId: `source-${crypto.randomUUID()}`, + isLocalProject: true, + }, + ), + ); + const loadOptional = (mod as unknown as { + loadOptional(enabled: boolean): Promise; + }).loadOptional; + + assertEquals(await loadOptional(false), "SKIPPED"); + await assertRejects( + () => loadOptional(true), + Error, + `Missing module: _vf_modules/${missingModule}.js`, + ); + } finally { + mdxRenderer.clearCache(); + await Deno.remove(projectDir, { recursive: true }); + const esbuild = await import("veryfront/extensions/bundler"); + await esbuild.stop(); + } + }); + + it("defers a typed dependency failure until the root import executes", async () => { + const oversizedModule = `OversizedRoot-${crypto.randomUUID()}.js`; + const projectDir = await Deno.makeTempDir({ prefix: "vf-mdx-root-dynamic-limit-" }); + + try { + const mod = await withMockFetch( + () => + Promise.resolve( + new Response("x".repeat(MAX_MDX_MODULE_CODE_BYTES + 1), { + headers: { "content-type": "application/javascript" }, + }), + ), + () => + mdxRenderer.loadModuleESM( + `export async function loadOptional(enabled) { + if (!enabled) return "SKIPPED"; + return await import("@/${oversizedModule}"); + } + export default function Root() { return null; }`, + { + adapter: denoAdapter, + projectId: `project-${crypto.randomUUID()}`, + projectDir, + projectSlug: "root-dynamic-limit", + contentSourceId: `source-${crypto.randomUUID()}`, + isLocalProject: true, + }, + ), + ); + const loadOptional = (mod as unknown as { + loadOptional(enabled: boolean): Promise; + }).loadOptional; + + assertEquals(await loadOptional(false), "SKIPPED"); + const error = await assertRejects( + () => loadOptional(true), + Error, + "module source exceeds the allowed size", + ); + if (!(error instanceof Error)) throw new Error("expected Error"); + assertEquals(error.name, "ModuleSourceLimitError"); + } finally { + mdxRenderer.clearCache(); + await Deno.remove(projectDir, { recursive: true }); + const esbuild = await import("veryfront/extensions/bundler"); + await esbuild.stop(); + } + }); +}); + describe("verifyMdxCacheFile", () => { const { verifyMdxCacheFile } = __moduleWriterInternals; diff --git a/src/transforms/mdx/esm-module-loader/types.ts b/src/transforms/mdx/esm-module-loader/types.ts index 21c1a52fdb..f485d5b6bb 100644 --- a/src/transforms/mdx/esm-module-loader/types.ts +++ b/src/transforms/mdx/esm-module-loader/types.ts @@ -3,6 +3,7 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { Logger } from "#veryfront/utils"; import type { MDXModule } from "../types.ts"; import type { DependencyPinningSourceInput } from "#veryfront/transforms/esm/package-registry.ts"; +import type { DeferredImportErrorDescriptor } from "./utils/stub-module.ts"; export interface ESMLoaderContext { esmCacheDir?: string; @@ -60,6 +61,7 @@ export interface NestedImportResult { isSideEffect?: boolean; suffix?: string; nestedFilePath: string | null; + deferredError?: DeferredImportErrorDescriptor; nestedPath?: string; relativePath?: string; } diff --git a/src/transforms/mdx/esm-module-loader/utils/source-spans.test.ts b/src/transforms/mdx/esm-module-loader/utils/source-spans.test.ts index 203b96f13b..80d36f438b 100644 --- a/src/transforms/mdx/esm-module-loader/utils/source-spans.test.ts +++ b/src/transforms/mdx/esm-module-loader/utils/source-spans.test.ts @@ -12,6 +12,29 @@ import { // it collects, opt out of the bound explicitly. const UNBOUNDED = Number.MAX_SAFE_INTEGER; +function countStartsWithCalls(callback: () => void): number { + const original = String.prototype.startsWith; + let calls = 0; + Object.defineProperty(String.prototype, "startsWith", { + configurable: true, + writable: true, + value(this: string, searchString: string, position?: number) { + calls++; + return original.call(this, searchString, position); + }, + }); + try { + callback(); + } finally { + Object.defineProperty(String.prototype, "startsWith", { + configurable: true, + writable: true, + value: original, + }); + } + return calls; +} + describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { it("keeps static imports inside regexes hidden after local type export lists", () => { const matchRelative = (specifier: string) => specifier.startsWith("./") ? specifier : null; @@ -248,6 +271,17 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { ); }); + it("keeps value and type re-export forms eligible for from clauses", () => { + assertEquals( + findStaticImportFromSpans( + 'export * from "./all.js"; export type { Value } from "./types.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./all.js", "./types.js"], + ); + }); + it("finds static imports after top-level block declarations", () => { assertEquals( findStaticImportFromSpans( @@ -259,6 +293,62 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { ); }); + it("ignores import-from examples in JSX text after an expression", () => { + assertEquals( + findStaticImportFromSpans( + `export function Example() { + return {label}import value from /* note */ "./example.js";; +} +import real from "./real.js";`, + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./real.js"], + ); + }); + + it("keeps JSX closing-tag checks linear for repeated angle assertions", () => { + const repeatedAssertions = Array.from( + { length: 3_000 }, + (_, index) => `const value${index} = input${index};`, + ).join("\n"); + const source = `${repeatedAssertions}\nimport real from "./real.js";`; + let paths: string[] = []; + + const startsWithCalls = countStartsWithCalls(() => { + paths = findStaticImportFromSpans(source, matchRelative, UNBOUNDED) + .map((span) => span.path); + }); + + assertEquals(paths, ["./real.js"]); + assert( + startsWithCalls < source.length * 3, + `Expected a linear static import scan, got ${startsWithCalls} startsWith calls ` + + `for ${source.length} source characters`, + ); + }); + + it("keeps side-effect JSX closing-tag checks linear for repeated angle assertions", () => { + const repeatedAssertions = Array.from( + { length: 3_000 }, + (_, index) => `const value${index} = input${index};`, + ).join("\n"); + const source = `${repeatedAssertions}\nimport "./real.js";`; + let paths: string[] = []; + + const startsWithCalls = countStartsWithCalls(() => { + paths = findStaticSideEffectImportSpans(source, matchRelative, UNBOUNDED) + .map((span) => span.path); + }); + + assertEquals(paths, ["./real.js"]); + assert( + startsWithCalls < source.length * 3, + `Expected a linear side-effect import scan, got ${startsWithCalls} startsWith calls ` + + `for ${source.length} source characters`, + ); + }); + it("finds static imports after ambient TypeScript declarations", () => { assertEquals( findStaticImportFromSpans( @@ -1083,6 +1173,69 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { ); }); + it("ignores import-looking regex text after exported declarations at ASI boundaries", () => { + assertEquals( + vfModuleSpecifiers( + 'export function f() {}\n/import("\\/_vf_modules\\/fake-function.js")/.test(value);', + ), + [], + ); + assertEquals( + vfModuleSpecifiers( + 'export class C {}\n/import("\\/_vf_modules\\/fake-class.js")/.test(value);', + ), + [], + ); + assertEquals( + vfModuleSpecifiers( + 'export default function () {}\n/import("\\/_vf_modules\\/fake-default-function.js")/.test(value);', + ), + [], + ); + assertEquals( + vfModuleSpecifiers( + 'export default class {}\n/import("\\/_vf_modules\\/fake-default-class.js")/.test(value);', + ), + [], + ); + }); + + it("ignores import-looking regex text after export lists at ASI boundaries", () => { + assertEquals( + specifiers( + 'const value = 1; export { value }\n/import("\\.\\/fake-export-list.js")/.test(input);', + ), + [], + ); + assertEquals( + vfModuleSpecifiers( + 'const value = 1; export { value }\n/import("\\/_vf_modules\\/fake-export-list.js")/.test(input);', + ), + [], + ); + }); + + it("ignores import-looking regex text after Unicode declaration names", () => { + assertEquals( + specifiers('function λ() {}\n/import("\\.\\/fake-function.js")/.test(value);'), + [], + ); + assertEquals( + specifiers('class Ω {}\n/import("\\.\\/fake-class.js")/.test(value);'), + [], + ); + assertEquals( + specifiers( + 'function \\u0061() {}\n/import("\\.\\/fake-escaped-function.js")/.test(value);', + ), + [], + ); + assertEquals( + specifiers('class \\u{41} {}\n/import("\\.\\/fake-escaped-class.js")/.test(value);'), + [], + ); + }); + it("recognizes Unicode line terminators in declaration comments", () => { for (const lineTerminator of ["\u2028", "\u2029"]) { assertEquals( @@ -1134,6 +1287,121 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { ); }); + it("ignores import-looking regex text after for-await loops", () => { + assertEquals( + specifiers( + 'for await (const value of []) {}\n/import("\\.\\/fake.js")/.test(value);', + ), + [], + ); + assertEquals( + specifiers( + 'for /* stream */ await (const value of []) {}\n/import("\\.\\/commented-fake.js")/.test(value);', + ), + [], + ); + }); + + it("finds the tenant alias import after division by a default property", () => { + const spans = findDynamicImportSpans( + 'const half = mod.default / 2; const L = lazy(() => import("@/components/Chart"));', + (specifier) => specifier.startsWith("@/") ? specifier : null, + UNBOUNDED, + ); + + assertEquals(spans.map((span) => span.path), ["@/components/Chart"]); + }); + + it("finds imports after commented reserved-name member divisions", () => { + assertEquals( + specifiers( + 'const direct = mod./* note */default / 2; import("./after-direct.js");', + ), + ["./after-direct.js"], + ); + assertEquals( + specifiers( + 'const optional = mod?./* note */default / 2; import("./after-optional.js");', + ), + ["./after-optional.js"], + ); + assertEquals( + specifiers( + 'const direct = mod.// note\ntypeof / 2; import("./after-line-direct.js");', + ), + ["./after-line-direct.js"], + ); + assertEquals( + specifiers( + 'const optional = mod?.// note\ntypeof / 2; import("./after-line-optional.js");', + ), + ["./after-line-optional.js"], + ); + }); + + it("still treats genuine keyword positions as regex prefixes", () => { + assertEquals( + specifiers('const t = typeof /re/; import("./after-typeof-keyword.js");'), + ["./after-typeof-keyword.js"], + ); + assertEquals( + specifiers( + 'function f() { return /re/.test(x); } import("./after-return-keyword.js");', + ), + ["./after-return-keyword.js"], + ); + assertEquals( + specifiers( + '[...typeof /import(".\\/fake-spread.js")/]; import("./after-spread.js");', + ), + ["./after-spread.js"], + ); + assertEquals( + specifiers( + 'switch (v) { case /re/.source: break; } import("./after-case-keyword.js");', + ), + ["./after-case-keyword.js"], + ); + assertEquals( + specifiers( + 'for (const x of /re/.exec(s) ?? []) {} import("./after-for-of-regex.js");', + ), + ["./after-for-of-regex.js"], + ); + }); + + // The for-await search runs over raw text, so it also finds a `for` that is + // not code. A block comment cannot fool it because the `*/` terminator + // stops the adjacency scan. A line comment ends at a newline, which the scan + // treats as ordinary whitespace and walks straight through. A comment whose + // last word is `for` sitting above a top-level `await (...)` therefore read as + // a for-await header, and the `/` that actually divides was taken as a regex + // opening, hiding a real dynamic import inside it. + it("does not read a line comment ending in for as a for-await header", () => { + assertEquals( + specifiers('// for\nawait (ready)\n/import(".\\/after-line-comment.js")/.source;'), + ["./after-line-comment.js"], + ); + assertEquals( + specifiers( + '// what we are waiting for\nawait (ready)\n/import(".\\/after-prose-comment.js")/.source;', + ), + ["./after-prose-comment.js"], + ); + }); + + // The converse over-correction: `//` inside a string is not a comment, so a + // URL on the same line must not stop a genuine for-await header from being + // recognized. Otherwise, the phantom-import bug would return. + it("still reads a for-await header on a line holding a url string", () => { + assertEquals( + specifiers( + 'const origin = "http://example.test"; for await (const value of source) {}\n/import(".\\/url-line-fake.js")/.test(value);', + ), + [], + ); + }); + it("ignores import-looking regex text inside template substitutions", () => { assertEquals( specifiers( @@ -1612,6 +1880,15 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { } }); + it("keeps class context across nested extends braces", () => { + assertEquals( + vfModuleSpecifiers( + 'class Loader extends mixin({}) {} /import("\\/_vf_modules\\/fake.js")/.test(value);', + ), + [], + ); + }); + it("treats Unicode identifier parts as import boundaries", () => { for ( const source of [ @@ -1641,6 +1918,15 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { } }); + it("recognizes regex literals after arrow function bodies at ASI boundaries", () => { + assertEquals( + vfModuleSpecifiers( + 'const load = () => {}\n/import("\\/_vf_modules\\/fake.js")/.test(value);', + ), + [], + ); + }); + it("does not read keyword suffixes in longer identifiers as regex prefixes", () => { for ( const source of [ @@ -1906,6 +2192,17 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { } }); + it("finds side-effect imports after block comments with line terminators", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'const ready = true /* note\n */ import "/_vf_modules/after-comment.js";', + (specifier) => specifier.startsWith("/_vf_modules/") ? specifier : null, + UNBOUNDED, + ).map((span) => span.path), + ["/_vf_modules/after-comment.js"], + ); + }); + it("ignores side-effect import text inside regex literals", () => { assertEquals( findStaticSideEffectImportSpans( @@ -1917,6 +2214,93 @@ describe("transforms/mdx/esm-module-loader/utils/source-spans", () => { ); }); + it("ignores semicolon-terminated side-effect import text in JSX children", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'export function Example() { return {label}import "./example.js";; } import "./real.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./real.js"], + ); + }); + + it("ignores side-effect import text in namespaced JSX children", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'export function Example() { return {label}import "./example.js";; } import "./real.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./real.js"], + ); + }); + + it("ignores side-effect import text in Unicode namespaced JSX children", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'export function Example() { return {label}import "./example.js";; } import "./real.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./real.js"], + ); + }); + + it("ignores side-effect import text in Unicode-leading JSX children", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'export function Example() { return <路径>{label}import "./example.js";; } import "./real.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./real.js"], + ); + }); + + it("keeps scanning after a TypeScript angle-bracket assertion", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'const value = ""; import "./after-assertion.js";', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./after-assertion.js"], + ); + }); + + it("does not treat regex syntax as an assertion closing JSX tag", () => { + assertEquals( + findStaticSideEffectImportSpans( + 'const value = thing; import "./after-assertion.js"; const ok = x foo/.test(source);', + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./after-assertion.js"], + ); + }); + + it("recognizes regex syntax followed by binary operators", () => { + for ( + const continuation of [ + "&& ready", + "+ offset", + "=== expected", + "in expressions", + "instanceof RegExp", + ] + ) { + assertEquals( + findStaticSideEffectImportSpans( + `const value = thing; import "./after-assertion.js"; const ok = x foo/ ${continuation};`, + matchRelative, + UNBOUNDED, + ).map((span) => span.path), + ["./after-assertion.js"], + ); + } + }); + it("ignores side-effect import text in regex literals after comments", () => { assertEquals( findStaticSideEffectImportSpans( diff --git a/src/transforms/mdx/esm-module-loader/utils/source-spans.ts b/src/transforms/mdx/esm-module-loader/utils/source-spans.ts index 062dbdc92b..b533e9adc3 100644 --- a/src/transforms/mdx/esm-module-loader/utils/source-spans.ts +++ b/src/transforms/mdx/esm-module-loader/utils/source-spans.ts @@ -576,6 +576,28 @@ function previousSignificantIndex(source: string, index: number): number { return cursor; } +function lineCommentStart(source: string, index: number): number | null { + let cursor = index; + while (cursor > 0 && !isLineTerminator(source[cursor - 1] ?? "")) cursor--; + + let quote: string | null = null; + for (; cursor <= index; cursor++) { + const char = source[cursor]!; + if (quote !== null) { + if (char === "\\") cursor++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "/" && source[cursor + 1] === "/") return cursor; + } + + return null; +} + function previousSignificantIndexBeforeIgnored(source: string, index: number): number { let cursor = index; @@ -597,10 +619,12 @@ function previousSignificantIndexBeforeIgnored(source: string, index: number): n source.lastIndexOf("\u2028", cursor), source.lastIndexOf("\u2029", cursor), ) + 1; - const lineCommentStart = source.lastIndexOf("//", cursor); - if (lineCommentStart >= lineStart) { - cursor = lineCommentStart; - continue; + if (source.lastIndexOf("//", cursor) >= lineStart) { + const commentStart = lineCommentStart(source, cursor); + if (commentStart !== null) { + cursor = commentStart; + continue; + } } return cursor; @@ -634,7 +658,7 @@ function keywordBefore( * Covers `.name`, optional chaining `?.name` (the character before the word is * `.` either way) and private fields `#name`. */ -function isMemberNameBefore( +export function isMemberNameBefore( source: string, previousTokenIndex: number, ): boolean { @@ -643,11 +667,31 @@ function isMemberNameBefore( while (start > 0 && isIdentifierPartAt(source, start - 1)) start--; if (start === end) return false; - const before = previousSignificantIndex(source, start); + const immediateBefore = previousSignificantIndex(source, start); + if (immediateBefore < 0) return false; + + // Most keyword-shaped identifiers are ordinary expression operands. Avoid + // rescanning the whole line for a comment unless the adjacent trivia can + // actually contain one; doing that for every `of` makes long declarations + // quadratic under coverage instrumentation. + if ( + source[immediateBefore] !== "/" && + !hasLineTerminatorBetween(source, immediateBefore + 1, start) + ) { + const immediateChar = source[immediateBefore]; + if (immediateChar === "#") return true; + if (immediateChar !== ".") return false; + return source[immediateBefore - 1] !== "." || source[immediateBefore - 2] !== "."; + } + + const before = previousSignificantIndexBeforeIgnored(source, start); if (before < 0) return false; const char = source[before]; - return char === "." || char === "#"; + if (char === "#") return true; + if (char !== ".") return false; + + return source[before - 1] !== "." || source[before - 2] !== "."; } function restrictedStatementKeywordBeforeLabel( @@ -1276,6 +1320,20 @@ function isPlainStatementBlockCloseBrace( return matchingOpenBraces.get(index)?.isPlainStatementBlock === true; } +function isArrowFunctionBodyCloseBraceAtAsiBoundary( + source: string, + index: number, + nextTokenIndex: number, + matchingOpenBraces: ReadonlyMap, +): boolean { + const openBrace = matchingOpenBraces.get(index); + if (openBrace === undefined || source[openBrace.previousTokenIndex] !== ">") return false; + + const beforeArrow = previousSignificantIndex(source, openBrace.previousTokenIndex); + return source[beforeArrow] === "=" && + hasLineTerminatorBetween(source, index + 1, nextTokenIndex); +} + function isForOfKeywordBefore( source: string, rangeStart: number, @@ -1532,7 +1590,13 @@ function canStartRegexLiteral( ) || isDeclarationBlockCloseBrace(previous, matchingOpenBraces) || isStatementBlockCloseBrace(source, previous, matchingOpenBraces) || - isPlainStatementBlockCloseBrace(previous, matchingOpenBraces)) + isPlainStatementBlockCloseBrace(previous, matchingOpenBraces) || + isArrowFunctionBodyCloseBraceAtAsiBoundary( + source, + previous, + index, + matchingOpenBraces, + )) ) return true; if ( (char === "+" || char === "-") && @@ -1863,10 +1927,13 @@ function looksLikeTypeScriptAngleConstruct( } } + const quotedValueEnd = source[next] === '"' || source[next] === "'" + ? skipIgnored(source, next) + : next; if ( source[next] !== undefined && - (isIdentifierStartAt(source, next) || source[next] === "(") && - !hasRawJsxClosingTagBeforeStatementEnd(source, name, tagEnd, cache) + (isIdentifierStartAt(source, next) || source[next] === "(" || quotedValueEnd !== next) && + !hasRawJsxClosingTagBeforeStatementEnd(source, name, quotedValueEnd, cache) ) { const before = previousSignificantIndex(source, tagStart); return before >= 0 && "=(:,[!~?&|+-*%^<>".includes(source[before] ?? ""); @@ -2260,6 +2327,19 @@ function findFromSpan( return null; } +function canExportHaveFromClause(source: string, statementStart: number): boolean { + let cursor = skipWhitespaceAndComments(source, statementStart); + if ( + source.startsWith("type", cursor) && + !isIdentifierPartAt(source, cursor - 1) && + !isIdentifierPartAt(source, cursor + "type".length) + ) { + cursor = skipWhitespaceAndComments(source, cursor + "type".length); + } + + return source[cursor] === "*" || source[cursor] === "{"; +} + /** * Validate the match bound every scanner requires. * @@ -2443,6 +2523,13 @@ export function findStaticImportFromSpans( continue; } + if (isExport && !canExportHaveFromClause(source, afterKeyword)) { + atStatementStart = false; + previousTokenIndex = cursor + keywordLength - 1; + cursor = afterKeyword; + continue; + } + const span = findFromSpan(source, afterKeyword, matcher, isExport); if (span) { spans.push(span); diff --git a/src/transforms/mdx/esm-module-loader/utils/stub-module.ts b/src/transforms/mdx/esm-module-loader/utils/stub-module.ts index 76c1dad50f..6c8b512379 100644 --- a/src/transforms/mdx/esm-module-loader/utils/stub-module.ts +++ b/src/transforms/mdx/esm-module-loader/utils/stub-module.ts @@ -69,37 +69,75 @@ ${namedExports} `; } +export interface DeferredImportErrorDescriptor { + name: string; + message: string; +} + +function generateDeferredImportFailureCode( + modulePath: string, + deferredError?: DeferredImportErrorDescriptor, +): string { + const message = JSON.stringify( + deferredError?.message ?? + `[Veryfront] Missing module: ${modulePath}. This module or file does not exist in your project.`, + ); + const name = JSON.stringify(deferredError?.name ?? "MissingModuleError"); + return `const error = new Error(${message}); +error.name = ${name}; +throw error; +`; +} + +export interface CreateStubModuleOptions { + /** Reject a dynamic import when it executes instead of exporting fallback values. */ + failOnImport?: boolean; + /** Sanitized typed error to throw when a strict dynamic import executes. */ + deferredError?: DeferredImportErrorDescriptor; +} + export async function createStubModule( modulePath: string, code: string, importStatement: string, esmCacheDir: string, + options: CreateStubModuleOptions = {}, ): Promise { const namedImports = extractNamedImports(code, importStatement); - const stubHash = hashString(`stub:${modulePath}:${namedImports.join(",")}`); + const behavior = options.failOnImport ? "fail-on-import" : "fallback"; + const deferredIdentity = options.deferredError + ? `${options.deferredError.name}:${options.deferredError.message}` + : ""; + const stubHash = hashString( + `stub:${behavior}:${modulePath}:${namedImports.join(",")}:${deferredIdentity}`, + ); const stubPath = join(esmCacheDir, `stub-${stubHash}.mjs`); - const stubCode = generateStubCode(modulePath, namedImports); + const stubCode = options.failOnImport + ? generateDeferredImportFailureCode(modulePath, options.deferredError) + : generateStubCode(modulePath, namedImports); try { await getLocalFs().writeTextFile(stubPath, stubCode); - const errorMessage = namedImports.length - ? `Missing module: ${modulePath} (imports: ${namedImports.join(", ")})` - : `Missing module: ${modulePath}`; + if (!options.failOnImport) { + const errorMessage = namedImports.length + ? `Missing module: ${modulePath} (imports: ${namedImports.join(", ")})` + : `Missing module: ${modulePath}`; + + try { + getErrorCollector().addModuleError(errorMessage, modulePath, { + namedImports, + importStatement, + }); + } catch (_) { + /* expected: error collector may not be initialized in all contexts */ + } - try { - getErrorCollector().addModuleError(errorMessage, modulePath, { + logger.error(`${LOG_PREFIX_MDX_LOADER} Missing module: ${modulePath}`, { namedImports, - importStatement, }); - } catch (_) { - /* expected: error collector may not be initialized in all contexts */ } - logger.error(`${LOG_PREFIX_MDX_LOADER} Missing module: ${modulePath}`, { - namedImports, - }); - return stubPath; } catch (error) { logger.error( diff --git a/src/transforms/pipeline/stages/compile.test.ts b/src/transforms/pipeline/stages/compile.test.ts index d83f21c669..fa8e7e7708 100644 --- a/src/transforms/pipeline/stages/compile.test.ts +++ b/src/transforms/pipeline/stages/compile.test.ts @@ -1,7 +1,21 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertExists, + assertInstanceOf, + assertRejects, + assertStrictEquals, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import { stop as stopEsbuild } from "#veryfront/platform/compat/esbuild.ts"; +import { VeryfrontError } from "#veryfront/errors"; +import type { Bundler } from "#veryfront/extensions/bundler/bundler.ts"; +import { + register as registerContract, + tryResolve as tryResolveContract, + unregister as unregisterContract, +} from "#veryfront/extensions/contracts.ts"; import { compilePlugin } from "./compile.ts"; import { TransformStage } from "../types.ts"; import type { TransformContext } from "../types.ts"; @@ -24,6 +38,26 @@ function createContext(code: string, filePath = "/project/lib/x.ts"): TransformC } as TransformContext; } +async function transformWithBundlerFailure(cause: Error): Promise { + const previous = tryResolveContract("Bundler"); + registerContract("Bundler", { + bundle: () => Promise.reject(new Error("not used")), + transform: () => Promise.reject(cause), + }); + + try { + const error = await assertRejects( + async () => await compilePlugin.transform(createContext("export const value = 1;")), + VeryfrontError, + ); + assertInstanceOf(error, VeryfrontError); + return error; + } finally { + if (previous) registerContract("Bundler", previous); + else unregisterContract("Bundler"); + } +} + describe("transforms/pipeline/stages/compile", () => { afterAll(async () => { await stopEsbuild(); @@ -129,4 +163,145 @@ describe("transforms/pipeline/stages/compile", () => { assertStringIncludes(result, 'await Promise.resolve("production")'); }); }); + + describe("error classification", () => { + it("marks esbuild source diagnostics as tenant build failures", async () => { + const error = await assertRejects( + async () => await compilePlugin.transform(createContext("export const value = ;")), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "compilation-error"); + assertEquals( + (error.context as { tenantBuildFailure?: unknown } | undefined)?.tenantBuildFailure, + true, + ); + }); + + it("does not use an inherited esbuild diagnostic collection", async () => { + const marker = Symbol.for("veryfront.bundler.esbuild-source-diagnostic"); + const previousErrors = Object.getOwnPropertyDescriptor(Error.prototype, "errors"); + const previousMarker = Object.getOwnPropertyDescriptor(Error.prototype, marker); + const frameworkFailure = new Error("esbuild service stopped"); + Object.defineProperty(Error.prototype, "errors", { + configurable: true, + value: [{ location: { line: 1, column: 1 } }], + }); + Object.defineProperty(Error.prototype, marker, { configurable: true, value: true }); + + try { + const error = await transformWithBundlerFailure(frameworkFailure); + assertStrictEquals(error.cause, frameworkFailure); + assertEquals( + (error.context as { tenantBuildFailure?: unknown } | undefined)?.tenantBuildFailure, + false, + ); + } finally { + if (previousErrors) Object.defineProperty(Error.prototype, "errors", previousErrors); + else delete (Error.prototype as { errors?: unknown }).errors; + if (previousMarker) Object.defineProperty(Error.prototype, marker, previousMarker); + else delete (Error.prototype as { [marker]?: unknown })[marker]; + } + }); + + it("does not use inherited esbuild diagnostic locations", async () => { + const frameworkFailure = new Error("esbuild service stopped"); + Object.defineProperty(frameworkFailure, "errors", { + value: [Object.create({ location: { line: 1, column: 1 } })], + }); + + const error = await transformWithBundlerFailure(frameworkFailure); + assertStrictEquals(error.cause, frameworkFailure); + assertEquals( + (error.context as { tenantBuildFailure?: unknown } | undefined)?.tenantBuildFailure, + false, + ); + }); + + it("does not invoke accessor-backed esbuild diagnostic fields", async () => { + const marker = Symbol.for("veryfront.bundler.esbuild-source-diagnostic"); + let errorsGetterReads = 0; + let markerGetterReads = 0; + const accessorCollectionFailure = new Error("esbuild service stopped"); + Object.defineProperty(accessorCollectionFailure, "errors", { + get() { + errorsGetterReads++; + return [{ location: { line: 1, column: 1 } }]; + }, + }); + Object.defineProperty(accessorCollectionFailure, marker, { + get() { + markerGetterReads++; + return true; + }, + }); + + const collectionError = await transformWithBundlerFailure(accessorCollectionFailure); + assertEquals( + (collectionError.context as { tenantBuildFailure?: unknown } | undefined) + ?.tenantBuildFailure, + false, + ); + assertEquals(errorsGetterReads, 0); + assertEquals(markerGetterReads, 0); + + let locationGetterReads = 0; + const diagnostic = Object.defineProperty({}, "location", { + get() { + locationGetterReads++; + return { line: 1, column: 1 }; + }, + }); + const accessorLocationFailure = new Error("esbuild service stopped"); + Object.defineProperty(accessorLocationFailure, "errors", { value: [diagnostic] }); + + const locationError = await transformWithBundlerFailure(accessorLocationFailure); + assertEquals( + (locationError.context as { tenantBuildFailure?: unknown } | undefined) + ?.tenantBuildFailure, + false, + ); + assertEquals(locationGetterReads, 0); + }); + + // By the time an `.mdx` file reaches COMPILE, PARSE has already turned the + // tenant's source into JSX, so `ctx.code` is the framework's MDX-compiler + // output. A remark/rehype/recma plugin emitting broken JSX still yields an + // esbuild diagnostic with a valid location — pointing into generated code. + // Claiming tenant ownership there would downgrade a broken content-MDX + // release to a Sentry warning and nobody would be paged. + it("does not claim tenant ownership of a diagnostic in MDX-compiler output", async () => { + const error = await assertRejects( + async () => + await compilePlugin.transform( + createContext("export const value = ;", "/project/app/post.mdx"), + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "compilation-error"); + assertEquals( + (error.context as { tenantBuildFailure?: unknown } | undefined)?.tenantBuildFailure, + false, + ); + }); + + it("does not claim tenant ownership of a diagnostic in Markdown-compiler output", async () => { + const error = await assertRejects( + async () => + await compilePlugin.transform( + createContext("export const value = ;", "/project/app/post.md"), + ), + VeryfrontError, + ); + + assertInstanceOf(error, VeryfrontError); + assertEquals( + (error.context as { tenantBuildFailure?: unknown } | undefined)?.tenantBuildFailure, + false, + ); + }); + }); }); diff --git a/src/transforms/pipeline/stages/compile.ts b/src/transforms/pipeline/stages/compile.ts index a82dc20f66..d95e9995ed 100644 --- a/src/transforms/pipeline/stages/compile.ts +++ b/src/transforms/pipeline/stages/compile.ts @@ -7,6 +7,54 @@ import { ESBUILD_SUPPORTED_FEATURES, getLoaderFromPath } from "../../esm/transfo import { type TransformContext, type TransformPlugin, TransformStage } from "../types.ts"; const logger = rendererLogger.component("esm-transform"); +const ESBUILD_SOURCE_DIAGNOSTIC = Symbol.for( + "veryfront.bundler.esbuild-source-diagnostic", +); +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectApply = Reflect.apply; +const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor; + +function readOwnDataProperty(value: unknown, key: PropertyKey): unknown { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") + ) { + return undefined; + } + try { + const descriptor = ReflectGetOwnPropertyDescriptor(value, key); + if ( + descriptor !== undefined && + ReflectApply(ObjectPrototypeHasOwnProperty, descriptor, ["value"]) === true + ) { + return descriptor.value; + } + } catch { + // A hostile proxy cannot provide trusted source-diagnostic evidence. + } + return undefined; +} + +function isEsbuildSourceDiagnostic(error: unknown): boolean { + return readOwnDataProperty(error, ESBUILD_SOURCE_DIAGNOSTIC) === true; +} + +/** + * `.mdx` and `.md` reach this stage as *generated* JSX: PARSE has already run + * the MDX compiler over the tenant's source, so `ctx.code` here is framework + * output. A diagnostic with a location points into that generated code, not + * into anything the project wrote, so it must not claim tenant ownership — a + * remark/rehype/recma plugin emitting broken JSX is a framework fault that has + * to page someone. + * + * Nothing is lost by refusing to infer ownership for these two extensions: + * genuine MDX and Markdown *source* errors are classified upstream at PARSE as + * `mdx-compile-error` / `markdown-compile-error`, both of which the shared + * tenant classifier already recognizes. + */ +function isGeneratedContentOutput(filePath: string): boolean { + return filePath.endsWith(".mdx") || filePath.endsWith(".md"); +} export const compilePlugin: TransformPlugin = { name: "esbuild-compile", @@ -70,6 +118,10 @@ export const compilePlugin: TransformPlugin = { throw COMPILATION_ERROR.create({ detail: `ESM transform failed for ${ctx.filePath} (loader: ${loader}): ${errorMsg}`, cause: err, + context: { + tenantBuildFailure: !isGeneratedContentOutput(ctx.filePath) && + isEsbuildSourceDiagnostic(err), + }, }); } },