diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 5cdc05c884..ed9f4c06c5 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -92,7 +92,7 @@ jobs: tests: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 35 name: tests (integration) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -107,6 +107,9 @@ jobs: with: node-version: "24" package-manager-cache: false + - name: Install Chromium for integration hydration regressions + timeout-minutes: 20 + uses: ./.github/actions/install-chromium - name: Install Node resolver test dependencies run: | npm ci --ignore-scripts --prefix tests/node/resolver-dependencies @@ -353,6 +356,21 @@ jobs: START=$(date +%s) deno task coverage:ci:shard -- --shard=${{ matrix.shard }}/4 --coverage-dir=coverage-shard-${{ matrix.shard }} echo "duration=$(($(date +%s) - START))s" >> "$GITHUB_OUTPUT" + - name: Include dependency history integration coverage + if: matrix.shard == 1 + run: | + rm -rf coverage-history + deno task test:file --coverage=coverage-history \ + tests/integration/semantic-unit-boundary/src/platform/adapters/fs/veryfront/dependency-metadata-history.test.ts \ + tests/integration/semantic-unit-boundary/src/platform/adapters/veryfront-api-client/dependency-metadata-history.test.ts \ + tests/integration/semantic-unit-boundary/src/transforms/esm/package-registry-metadata-history.test.ts + deno coverage coverage-history --include=src/ --include=cli/ --exclude=/tests/ --exclude=/__tests__/ --lcov > coverage-history.lcov + deno eval ' + import { mergeLcovReports } from "./scripts/test/coverage-ci.ts"; + const target = "coverage-shard-1/lcov.info"; + const reports = await Promise.all([target, "coverage-history.lcov"].map(path => Deno.readTextFile(path))); + await Deno.writeTextFile(target, mergeLcovReports(reports)); + ' - name: Upload unit coverage lcov if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/architecture/15-runtime-adapters.md b/docs/architecture/15-runtime-adapters.md index a5f4f3e83e..54c89bccc5 100644 --- a/docs/architecture/15-runtime-adapters.md +++ b/docs/architecture/15-runtime-adapters.md @@ -91,6 +91,51 @@ untrusted project code. Configured storage failures do not downgrade to local hi The provider, handle, record types, and handle factory are exported by `veryfront/platform`. +## API-derived dependency metadata history + +`FileSystemAdapter.readDependencyMetadataHistory()` is an optional read-only +capability, separate from `RuntimeAdapter.dependencySnapshotStore`. The Veryfront +filesystem implements it through the existing authenticated project API and the +same project-scoped token used for file reads. It does not obtain shared internal +credentials or expose a snapshot publication endpoint to project code. + +Before a dependency-resolution write changes `package.json`, the API acknowledges +storage of its own observed prior dependency map, including an absent file as an +empty map. Publication atomically bounds unexpired history to 16 maps and 960 KiB +per project/branch. A full budget defers automatic writeback instead of dropping a +retained map. The read endpoint returns that history with a response limit of 1 MiB. +The project and canonical branch scope are derived by the API, with the requested +branch retained in the response for matching. + +The optional reader accepts an `AbortSignal`. The registry's five-second deadline +aborts the underlying metadata request, so cooperative reads release their admission +slots when the endpoint stalls. Concurrent keys from one source share the same +full-history read, then independently validate their requested key. Settled history +is retained for one second, including misses, with limits of 32 sources and 8 MiB +of serialized metadata. A change to the observed package dependency state invalidates +the source cache immediately; registry clearing also discards it. Only copied, +validated fields are retained, and original snapshot expiries still apply. Returned +dependency maps have a null prototype. + +Disabling pinning or reducing the rollout cohort stops new pinning. Exact historical +keys remain readable through their existing expiry, using the current captured +configuration and the same project/branch checks; recovery grants no writeback authority. + +A cold renderer consults this capability only after local history misses and the +current dependency key differs. It combines a prior raw map with its current +captured React/Veryfront configuration and accepts it only if the exact requested +key matches. Scope mismatch, corrupt data, outages and expired records fail closed. +Recovered data keeps its acknowledged expiry and never becomes current writeback +authority. Reader methods are captured before use and remain associated with their +original source when file reads are wrapped for tracking. + +This does not weaken the shared snapshot store's acknowledged publication contract: +an explicitly configured store never falls back to this metadata reader. Standalone +filesystems do not require an API or shared backend. Direct package edits, concurrent +configuration changes, expired history, and metadata changes predating the API +preimage publisher can remain unavailable. They still return a conflict rather +than interpreting an old key using current dependencies. + ## Change checks - Update [support matrix](./20-support-matrix.md) when runtime support changes. diff --git a/docs/architecture/20-support-matrix.md b/docs/architecture/20-support-matrix.md index 66a9d5a790..b482f4063f 100644 --- a/docs/architecture/20-support-matrix.md +++ b/docs/architecture/20-support-matrix.md @@ -32,24 +32,25 @@ These are the runtime capability profiles modeled by the framework. This matrix separates open-core framework support from capabilities that depend on a backing API or cloud bootstrap. -| Capability | Current support shape | Notes | -| --------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Routing, rendering, middleware, API routes | Open-core | Core framework capability. | -| Shared dependency snapshot history | Open-core with an explicit host provider | Requires native proxy detection on Deno, Node, or Bun. Hosts without it retain default process-local history. | -| App MCP server | Open-core on runtimes that can host it | Not available on constrained runtimes like Cloudflare Workers. | -| Internal AG-UI transport | Open-core runtime surface | Separate from the app MCP contract. | -| Direct provider integrations (`openai`, `anthropic`, `google`, local) | Open-core with provider credentials/runtime setup | Depends on the selected provider configuration. | -| Extension contracts (auth, bundler, CSS, parser, observability, etc.) | Open-core | First-party `@veryfront/ext-*` packages provide implementations. | -| Workflow engine (in-memory and Redis backends) | Open-core | In-memory, Redis, and process run execution work without Kubernetes. | -| Discovery (tools, agents, workflows, prompts, resources, skills) | Open-core | Convention-based file-system discovery at server startup. | -| Veryfront Cloud model routing | Requires Veryfront Cloud bootstrap | Depends on project/auth context and cloud gateway configuration. | -| Veryfront Cloud blob storage | Requires Veryfront Cloud bootstrap | Uses project-scoped cloud upload APIs. | -| Veryfront Cloud agent service | Requires Veryfront Cloud bootstrap | Hosted agent execution with project steering and runtime system messages. | -| Runs client | Requires backing API/service layer | Exposed as SDK/API surface for task, workflow, and agent execution. | -| Sandbox | Requires backing API/service layer | Depends on authenticated sandbox session APIs. | -| Remote integration tools | Requires backing API/service layer | Tool definitions and execution are fetched per request from the configured API layer. | -| Local catalog integration tools | Open-core with provider credentials/runtime setup | Exact-grant local sources execute supported HTTPS REST endpoints directly. Salesforce also has a dedicated service-account source. | -| Control-plane agent routing | Requires Veryfront Cloud bootstrap | EdDSA-signed request validation for hosted agent orchestration. | +| Capability | Current support shape | Notes | +| --------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing, rendering, middleware, API routes | Open-core | Core framework capability. | +| Shared dependency snapshot history | Open-core with an explicit host provider | Requires native proxy detection on Deno, Node, or Bun. Hosts without it retain default process-local history. | +| Prior dependency metadata | Optional Veryfront project API filesystem capability | Read-only recovery for dependency-resolution writebacks; requires exact project/branch and dependency-key matching. Not general shared snapshot storage. | +| App MCP server | Open-core on runtimes that can host it | Not available on constrained runtimes like Cloudflare Workers. | +| Internal AG-UI transport | Open-core runtime surface | Separate from the app MCP contract. | +| Direct provider integrations (`openai`, `anthropic`, `google`, local) | Open-core with provider credentials/runtime setup | Depends on the selected provider configuration. | +| Extension contracts (auth, bundler, CSS, parser, observability, etc.) | Open-core | First-party `@veryfront/ext-*` packages provide implementations. | +| Workflow engine (in-memory and Redis backends) | Open-core | In-memory, Redis, and process run execution work without Kubernetes. | +| Discovery (tools, agents, workflows, prompts, resources, skills) | Open-core | Convention-based file-system discovery at server startup. | +| Veryfront Cloud model routing | Requires Veryfront Cloud bootstrap | Depends on project/auth context and cloud gateway configuration. | +| Veryfront Cloud blob storage | Requires Veryfront Cloud bootstrap | Uses project-scoped cloud upload APIs. | +| Veryfront Cloud agent service | Requires Veryfront Cloud bootstrap | Hosted agent execution with project steering and runtime system messages. | +| Runs client | Requires backing API/service layer | Exposed as SDK/API surface for task, workflow, and agent execution. | +| Sandbox | Requires backing API/service layer | Depends on authenticated sandbox session APIs. | +| Remote integration tools | Requires backing API/service layer | Tool definitions and execution are fetched per request from the configured API layer. | +| Local catalog integration tools | Open-core with provider credentials/runtime setup | Exact-grant local sources execute supported HTTPS REST endpoints directly. Salesforce also has a dedicated service-account source. | +| Control-plane agent routing | Requires Veryfront Cloud bootstrap | EdDSA-signed request validation for hosted agent orchestration. | ## Extension contract matrix diff --git a/src/modules/server/module-server.ts b/src/modules/server/module-server.ts index efaa9cb4f5..c1e2358621 100644 --- a/src/modules/server/module-server.ts +++ b/src/modules/server/module-server.ts @@ -392,8 +392,10 @@ interface ModuleDependencyState { /** Serve transformed module at /_vf_modules/* path */ export function serveModule(req: Request, options: ModuleServerOptions): Promise { const url = new URL(req.url); - const pathPin = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1" - ? extractDependencyPinningPathKey(url.pathname) + const dependencyPinningEnabled = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1"; + const extractedPathPin = extractDependencyPinningPathKey(url.pathname); + const pathPin = dependencyPinningEnabled || extractedPathPin.found + ? extractedPathPin : { pathname: url.pathname, found: false, malformed: false }; if (pathPin.found && !pathPin.malformed) { url.pathname = pathPin.pathname; @@ -427,7 +429,6 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise const requestedPinKey = pathPin.found ? pathPin.cacheKey : queryPinValues[0]; const requestedPinCount = queryPinValues.length + (pathPin.found ? 1 : 0); const hasRequestedPinKey = requestedPinCount > 0; - const dependencyPinningEnabled = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1"; if ( pathPin.malformed || requestedPinCount > 1 || diff --git a/src/platform/adapters/base.ts b/src/platform/adapters/base.ts index bdc418fc15..11a9425fad 100644 --- a/src/platform/adapters/base.ts +++ b/src/platform/adapters/base.ts @@ -372,6 +372,10 @@ export interface FileSystemAdapter { * callers must not carry freshness across a possible context change. */ getSourceSnapshotIdentity?(): string | undefined | Promise; + /** Read trusted prior dependency metadata for this adapter's mutable source scope. */ + readDependencyMetadataHistory?(signal?: AbortSignal): Promise< + import("./dependency-metadata-history.ts").DependencyMetadataHistory + >; } /** A filesystem adapter that advertises genuine bounded byte reads. */ diff --git a/src/platform/adapters/dependency-metadata-history.ts b/src/platform/adapters/dependency-metadata-history.ts new file mode 100644 index 0000000000..ab628e1a19 --- /dev/null +++ b/src/platform/adapters/dependency-metadata-history.ts @@ -0,0 +1,11 @@ +export interface DependencyMetadataHistoryEntry { + readonly dependencies: Readonly>; + readonly expiresAt: number; +} + +export interface DependencyMetadataHistory { + readonly version: 1; + readonly projectId: string; + readonly branch: string | null; + readonly entries: readonly DependencyMetadataHistoryEntry[]; +} diff --git a/src/platform/adapters/fs/index.ts b/src/platform/adapters/fs/index.ts index 5eedb7b0af..aa993bea0b 100644 --- a/src/platform/adapters/fs/index.ts +++ b/src/platform/adapters/fs/index.ts @@ -31,3 +31,7 @@ export { wrapFSAdapter, } from "./wrapper.ts"; export type { ExtendedFileSystemAdapter } from "./wrapper.ts"; +export type { + DependencyMetadataHistory, + DependencyMetadataHistoryEntry, +} from "../dependency-metadata-history.ts"; diff --git a/src/platform/adapters/fs/veryfront/adapter.ts b/src/platform/adapters/fs/veryfront/adapter.ts index 0ee60887fd..aaa7fed8c0 100644 --- a/src/platform/adapters/fs/veryfront/adapter.ts +++ b/src/platform/adapters/fs/veryfront/adapter.ts @@ -1,4 +1,5 @@ import { logger as baseLogger } from "#veryfront/utils"; +import { awaitAbortable, throwIfAborted } from "#veryfront/utils/abort.ts"; import { createHash, type Hash } from "node:crypto"; import { createError, toError } from "#veryfront/errors"; import type { @@ -17,6 +18,7 @@ import type { ResolveFileOptions, SourceSnapshotFreshnessOptions, } from "#veryfront/platform/adapters/base.ts"; +import type { DependencyMetadataHistory } from "#veryfront/platform/adapters/dependency-metadata-history.ts"; import { VeryfrontApiClient } from "../../veryfront-api-client/index.ts"; import type { Project } from "../../veryfront-api-client/index.ts"; import { FileCache } from "../cache/file-cache.ts"; @@ -1640,6 +1642,17 @@ export class VeryfrontFSAdapter implements FSAdapter { return this.#getCurrentSourceSnapshotIdentity(); } + async readDependencyMetadataHistory(signal?: AbortSignal): Promise { + throwIfAborted(signal); + await awaitAbortable(this.#ensureExactReadInitialized(), signal); + const source = this.getEffectiveContentContext(); + if (source?.sourceType !== "branch") { + throw new TypeError("Dependency metadata history is available only for branch sources"); + } + const branch = source.branch && source.branch !== "main" ? source.branch : null; + return await this.client.readDependencyMetadataHistory(branch, signal); + } + getPokeMetrics(): { received: number; invalidationsTriggered: number; diff --git a/src/platform/adapters/fs/veryfront/multi-project-adapter.ts b/src/platform/adapters/fs/veryfront/multi-project-adapter.ts index 49032c2ca3..952ac6b899 100644 --- a/src/platform/adapters/fs/veryfront/multi-project-adapter.ts +++ b/src/platform/adapters/fs/veryfront/multi-project-adapter.ts @@ -1,4 +1,5 @@ import { logger as baseLogger } from "#veryfront/utils/logger/logger.ts"; +import { awaitAbortable, throwIfAborted } from "#veryfront/utils/abort.ts"; import { INITIALIZATION_ERROR } from "#veryfront/errors/error-registry.ts"; import type { DirectoryEntry, FSAdapter, FSAdapterConfig } from "./types.ts"; import type { @@ -6,6 +7,7 @@ import type { ResolveFileOptions, SourceSnapshotFreshnessOptions, } from "#veryfront/platform/adapters/base.ts"; +import type { DependencyMetadataHistory } from "#veryfront/platform/adapters/dependency-metadata-history.ts"; import { ProxyFSAdapterManager } from "./proxy-manager.ts"; import { VeryfrontFSAdapter } from "./adapter.ts"; import { runWithCacheBatching } from "#veryfront/cache/request-cache-batcher.ts"; @@ -59,6 +61,8 @@ const VeryfrontFSAdapterGetSourceSnapshotFingerprint = VeryfrontFSAdapterPrototype.getSourceSnapshotFingerprint; const VeryfrontFSAdapterGetSourceSnapshotIdentity = VeryfrontFSAdapterPrototype.getSourceSnapshotIdentity; +const VeryfrontFSAdapterReadDependencyMetadataHistory = + VeryfrontFSAdapterPrototype.readDependencyMetadataHistory; type CapturedAdapterMethod = (...args: never[]) => unknown; type CapturedManagerMethod = (...args: never[]) => unknown; @@ -95,7 +99,8 @@ function captureEffectiveAdapterMethod( | "ensureSourceSnapshotFresh" | "getSourceSnapshotVersion" | "getSourceSnapshotFingerprint" - | "getSourceSnapshotIdentity", + | "getSourceSnapshotIdentity" + | "readDependencyMetadataHistory", concreteMethod: CapturedAdapterMethod, ): CapturedAdapterMethod { const ownDescriptor = IntrinsicReflectApply( @@ -594,6 +599,26 @@ export class MultiProjectFSAdapter implements FSAdapter { return `adapter:${generation}:${sourceIdentity}`; } + async readDependencyMetadataHistory(signal?: AbortSignal): Promise { + throwIfAborted(signal); + const adapter = await awaitAbortable(this.#getAdapter(), signal); + if (!isConcreteVeryfrontFSAdapter(adapter)) { + const reader = adapter.readDependencyMetadataHistory; + if (typeof reader !== "function") { + throw new TypeError( + "Selected Veryfront filesystem adapter cannot read dependency metadata history", + ); + } + return await IntrinsicReflectApply(reader, adapter, [signal]) as DependencyMetadataHistory; + } + const reader = captureEffectiveAdapterMethod( + adapter, + "readDependencyMetadataHistory", + VeryfrontFSAdapterReadDependencyMetadataHistory, + ); + return await IntrinsicReflectApply(reader, adapter, [signal]) as DependencyMetadataHistory; + } + dispose(): void { IntrinsicReflectApply(this.#managerDispose, this.#manager, []); this.defaultAdapter?.dispose(); diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index 05328d1938..80a7102663 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -556,14 +556,17 @@ describe("ProxyFSAdapterManager", () => { it("disposes idle adapters and keeps adapters inside their idle window", async () => { const idleDisposedSlugs: string[] = []; const retainedDisposedSlugs: string[] = []; + let clock = 1_000_000; const idleManager = createManager({ cleanupIntervalMs: 5, maxIdleMs: 0, + now: () => clock, adapterFactory: createRecordingAdapterFactory(idleDisposedSlugs), }); const retainingManager = createManager({ cleanupIntervalMs: 5, maxIdleMs: 60_000, + now: () => clock, adapterFactory: createRecordingAdapterFactory(retainedDisposedSlugs), }); @@ -581,6 +584,7 @@ describe("ProxyFSAdapterManager", () => { assertEquals(idleManager.getStats().adapters, 1, "the idle adapter starts cached"); assertEquals(retainingManager.getStats().adapters, 1, "the fresh adapter starts cached"); + clock += 1; await waitFor(() => idleManager.getStats().adapters === 0, { message: "an idle adapter must be removed by the cleanup timer", }); diff --git a/src/platform/adapters/fs/veryfront/types.ts b/src/platform/adapters/fs/veryfront/types.ts index 974e3bc46f..3e83bb8e66 100644 --- a/src/platform/adapters/fs/veryfront/types.ts +++ b/src/platform/adapters/fs/veryfront/types.ts @@ -3,6 +3,7 @@ import type { SourceSnapshotFreshnessOptions, } from "#veryfront/platform/adapters/base.ts"; import type { Project } from "../../veryfront-api-client/index.ts"; +import type { DependencyMetadataHistory } from "../../dependency-metadata-history.ts"; import type { GitHubConfig } from "../github/types.ts"; import type { DirectoryEntry } from "../shared-types.ts"; @@ -69,6 +70,7 @@ export interface FSAdapter { * `#veryfront/platform/adapters/base.ts` for the contract. */ getSourceSnapshotIdentity?(): string | undefined | Promise; + readDependencyMetadataHistory?(signal?: AbortSignal): Promise; } export interface ContextualFSAdapter extends FSAdapter { diff --git a/src/platform/adapters/fs/wrapper.test.ts b/src/platform/adapters/fs/wrapper.test.ts index 7b2f1f9e21..a13ea32c83 100644 --- a/src/platform/adapters/fs/wrapper.test.ts +++ b/src/platform/adapters/fs/wrapper.test.ts @@ -113,6 +113,55 @@ describe("isExtendedFSAdapter", () => { }); describe("FSAdapterWrapper", () => { + describe("readDependencyMetadataHistory", () => { + it("captures and forwards the optional reader as a frozen data-property method", async () => { + const expected = { + version: 1 as const, + projectId: "10000000-1000-4000-8000-100000000001", + branch: null, + entries: [] as const, + }; + const controller = new AbortController(); + let observedSignal: AbortSignal | undefined; + const fsAdapter = createMockFSAdapter({ + readDependencyMetadataHistory(signal?: AbortSignal) { + observedSignal = signal; + return Promise.resolve(expected); + }, + }); + const wrapper = new FSAdapterWrapper(fsAdapter); + + assertEquals(await wrapper.readDependencyMetadataHistory?.(controller.signal), expected); + assertEquals(observedSignal, controller.signal); + const descriptor = Object.getOwnPropertyDescriptor( + wrapper, + "readDependencyMetadataHistory", + ); + assertEquals(typeof descriptor?.value, "function"); + assertEquals(descriptor?.writable, false); + assertEquals(descriptor?.configurable, false); + }); + + it("rejects an accessor-valued reader without invoking its getter", () => { + let getterCalls = 0; + const fsAdapter = createMockFSAdapter(); + Object.defineProperty(fsAdapter, "readDependencyMetadataHistory", { + configurable: true, + get() { + getterCalls++; + return () => Promise.resolve({ version: 1, projectId: "id", branch: null, entries: [] }); + }, + }); + + assertThrows( + () => new FSAdapterWrapper(fsAdapter), + TypeError, + "data-property method", + ); + assertEquals(getterCalls, 0); + }); + }); + describe("accessor methods", () => { it("getUnderlyingAdapter should return the wrapped FSAdapter", () => { const fsAdapter = createMockFSAdapter(); diff --git a/src/platform/adapters/fs/wrapper.ts b/src/platform/adapters/fs/wrapper.ts index 7bc8ab1e39..c0d7214cd5 100644 --- a/src/platform/adapters/fs/wrapper.ts +++ b/src/platform/adapters/fs/wrapper.ts @@ -7,6 +7,7 @@ import type { SourceSnapshotFreshnessOptions, WatchOptions, } from "#veryfront/platform/adapters/base.ts"; +import type { DependencyMetadataHistory } from "#veryfront/platform/adapters/dependency-metadata-history.ts"; import type { ContextualFSAdapter, DirectoryEntry, FSAdapter } from "./veryfront/types.ts"; import { captureByteReadCapabilities, @@ -128,6 +129,9 @@ export interface ExtendedFileSystemAdapter extends FileSystemAdapter { ) => Promise; readonly createFileBytesExclusive?: (path: string, content: Uint8Array) => Promise; readOptionalTextFile(path: string): Promise; + readonly readDependencyMetadataHistory?: ( + signal?: AbortSignal, + ) => Promise; readdir(path: string): Promise; shutdown(): Promise; } @@ -200,6 +204,9 @@ export class FSAdapterWrapper implements ExtendedFileSystemAdapter { | undefined | Promise; readonly getSourceSnapshotIdentity?: () => string | undefined | Promise; + readonly readDependencyMetadataHistory?: ( + signal?: AbortSignal, + ) => Promise; #textFileReader: CapturedTextFileReader; #contextRunner: CapturedContextRunner | undefined; @@ -306,6 +313,16 @@ export class FSAdapterWrapper implements ExtendedFileSystemAdapter { | undefined | Promise; } + const dependencyMetadataHistory = captureOptionalMethod( + fsAdapter, + "readDependencyMetadataHistory", + ); + if (dependencyMetadataHistory !== undefined) { + this.readDependencyMetadataHistory = (signal?: AbortSignal) => + IntrinsicReflectApply(dependencyMetadataHistory, fsAdapter, [signal]) as Promise< + DependencyMetadataHistory + >; + } const runWithContext = captureOptionalMethod(fsAdapter, "runWithContext"); if (runWithContext !== undefined) { const contextRunner: CapturedContextRunner = ( @@ -350,6 +367,7 @@ export class FSAdapterWrapper implements ExtendedFileSystemAdapter { "getSourceSnapshotVersion", "getSourceSnapshotFingerprint", "getSourceSnapshotIdentity", + "readDependencyMetadataHistory", ] as const ) { publishFrozen(this, key, this[key]); diff --git a/src/platform/adapters/index.ts b/src/platform/adapters/index.ts index a7a3763c02..768aa55c91 100644 --- a/src/platform/adapters/index.ts +++ b/src/platform/adapters/index.ts @@ -36,6 +36,10 @@ export type { DependencySnapshotStoreHandle, } from "./dependency-snapshot-store.ts"; export { createDependencySnapshotStoreHandle } from "./dependency-snapshot-store.ts"; +export type { + DependencyMetadataHistory, + DependencyMetadataHistoryEntry, +} from "./dependency-metadata-history.ts"; export { FileSnapshotChangedError, FileSnapshotPathError, diff --git a/src/platform/adapters/veryfront-api-client/client.ts b/src/platform/adapters/veryfront-api-client/client.ts index a466d62c42..c74a2204ac 100644 --- a/src/platform/adapters/veryfront-api-client/client.ts +++ b/src/platform/adapters/veryfront-api-client/client.ts @@ -17,6 +17,7 @@ import type { DependencyArtifactContentType, } from "#veryfront/release-assets/dependency-artifact-contracts.ts"; import { currentRequestContext } from "#veryfront/platform/request-context-access.ts"; +import type { DependencyMetadataHistory } from "../dependency-metadata-history.ts"; const logger = baseLogger.component("veryfront-api-client"); const IntrinsicObjectDefineProperty = Object.defineProperty; @@ -297,6 +298,18 @@ export class VeryfrontApiClient { return this.operations.getProject(projectRef ?? this.requireProjectSlug()); } + readDependencyMetadataHistory( + branch: string | null, + signal?: AbortSignal, + ): Promise { + return this.operations.readDependencyMetadataHistory( + this.requireProjectSlug(), + this.getProjectId(), + branch, + signal, + ); + } + // ============================================================================= // File Operations (context-aware) // ============================================================================= diff --git a/src/platform/adapters/veryfront-api-client/index.ts b/src/platform/adapters/veryfront-api-client/index.ts index 4051f0cedc..a14428dedd 100644 --- a/src/platform/adapters/veryfront-api-client/index.ts +++ b/src/platform/adapters/veryfront-api-client/index.ts @@ -5,6 +5,10 @@ */ export { type FileContext, VeryfrontApiClient } from "./client.ts"; +export type { + DependencyMetadataHistory, + DependencyMetadataHistoryEntry, +} from "../dependency-metadata-history.ts"; export { type EnsureStyleArtifactBuildInput, type FileDetail, diff --git a/src/platform/adapters/veryfront-api-client/operations.ts b/src/platform/adapters/veryfront-api-client/operations.ts index 3e6e9a02fa..060ccc9b59 100644 --- a/src/platform/adapters/veryfront-api-client/operations.ts +++ b/src/platform/adapters/veryfront-api-client/operations.ts @@ -50,6 +50,7 @@ import { type ReleaseAssetManifestStateResponse, type ReleaseAssetUploadResponse, } from "./schemas/index.ts"; +import type { DependencyMetadataHistory } from "../dependency-metadata-history.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { SpanNames } from "#veryfront/observability/tracing/span-names.ts"; import { copyFixedUint8ArrayWithinLimit } from "../file-system-capabilities.ts"; @@ -57,6 +58,139 @@ import { copyFixedUint8ArrayWithinLimit } from "../file-system-capabilities.ts"; const logger = baseLogger.component("api"); const DEFAULT_PAGE_LIMIT = 100; +const MAX_DEPENDENCY_METADATA_HISTORY_RESPONSE_BYTES = 1024 * 1024; +const IntrinsicArrayIsArray = Array.isArray; +const IntrinsicJSONParse = JSON.parse; +const IntrinsicObjectCreate = Object.create; +const IntrinsicObjectDefineProperty = Object.defineProperty; +const IntrinsicObjectFreeze = Object.freeze; +const IntrinsicObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const IntrinsicObjectGetPrototypeOf = Object.getPrototypeOf; +const IntrinsicReflectOwnKeys = Reflect.ownKeys; +const IntrinsicObjectPrototype = Object.prototype; +const IntrinsicNumberIsSafeInteger = Number.isSafeInteger; +const IntrinsicRegExpExec = RegExp.prototype.exec; +const MetadataProjectIdPattern = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; + +function invalidMetadataHistoryResponse(): Error { + return API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains an invalid response", + status: 502, + }); +} + +function metadataRecord(value: unknown): Record { + if (value === null || typeof value !== "object" || IntrinsicArrayIsArray(value)) { + throw invalidMetadataHistoryResponse(); + } + return value as Record; +} + +function metadataField(value: Record | readonly unknown[], key: string): unknown { + const descriptor = IntrinsicObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) throw invalidMetadataHistoryResponse(); + return descriptor.value; +} + +function metadataEntry(value: unknown): DependencyMetadataHistory["entries"][number] { + const entry = metadataRecord(value); + const expiresAt = metadataField(entry, "expires_at"); + if (typeof expiresAt !== "number" || !IntrinsicNumberIsSafeInteger(expiresAt) || expiresAt < 0) { + throw invalidMetadataHistoryResponse(); + } + return IntrinsicObjectFreeze({ + dependencies: parseDependencyMetadataMap(metadataField(entry, "dependencies")), + expiresAt, + }); +} + +function parseMetadataHistoryResponse( + value: unknown, + expectedProjectId: string, + expectedBranch: string | null, +): DependencyMetadataHistory { + const response = metadataRecord(value); + const version = metadataField(response, "version"); + const projectId = metadataField(response, "project_id"); + const branch = metadataField(response, "branch"); + const rawEntries = metadataField(response, "entries"); + if ( + version !== 1 || typeof projectId !== "string" || + tokenBoundaryApply(IntrinsicRegExpExec, MetadataProjectIdPattern, [projectId]) === null || + (branch !== null && typeof branch !== "string") + ) throw invalidMetadataHistoryResponse(); + if (projectId !== expectedProjectId || branch !== expectedBranch) { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history identity mismatch", + status: 502, + }); + } + if (!IntrinsicArrayIsArray(rawEntries) || rawEntries.length > 16) { + throw invalidMetadataHistoryResponse(); + } + const entries: Array = []; + for (let index = 0; index < rawEntries.length; index++) { + IntrinsicObjectDefineProperty(entries, `${index}`, { + configurable: false, + enumerable: true, + writable: false, + value: metadataEntry(metadataField(rawEntries, `${index}`)), + }); + } + return IntrinsicObjectFreeze({ + version: 1, + projectId, + branch, + entries: IntrinsicObjectFreeze(entries), + }); +} + +function parseDependencyMetadataMap(raw: unknown): Readonly> { + if (typeof raw !== "object" || raw === null || IntrinsicArrayIsArray(raw)) { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains an invalid dependency map", + status: 502, + }); + } + const prototype = IntrinsicObjectGetPrototypeOf(raw); + if (prototype !== IntrinsicObjectPrototype && prototype !== null) { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains an invalid dependency map", + status: 502, + }); + } + + const result = IntrinsicObjectCreate(null) as Record; + const keys = IntrinsicReflectOwnKeys(raw); + let index = 0; + while (index < keys.length) { + const key = keys[index++]; + if (typeof key !== "string") { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains an invalid dependency name", + status: 502, + }); + } + const descriptor = IntrinsicObjectGetOwnPropertyDescriptor(raw, key); + if ( + descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable || + typeof descriptor.value !== "string" + ) { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains an invalid dependency version", + status: 502, + }); + } + IntrinsicObjectDefineProperty(result, key, { + configurable: false, + enumerable: true, + value: descriptor.value, + writable: false, + }); + } + return IntrinsicObjectFreeze(result); +} function requireBoundedFileContentLimit(maximumBytes: number): number { if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { @@ -312,6 +446,39 @@ export class VeryfrontAPIOperations { return getProjectSchema().parse(raw); } + async readDependencyMetadataHistory( + projectRef: string, + expectedProjectId: string, + requestedBranch: string | null, + signal?: AbortSignal, + ): Promise { + const branch = requestedBranch === "main" ? null : requestedBranch; + const params = new URLSearchParams(); + if (branch !== null) params.set("branch", branch); + const query = params.toString(); + const endpoint = `/projects/${encodeURIComponent(projectRef)}/dependencies/history${ + query ? `?${query}` : "" + }`; + const raw = await this.request(endpoint, { + returnText: true, + maxResponseBytes: MAX_DEPENDENCY_METADATA_HISTORY_RESPONSE_BYTES, + includeErrorBodyInDiagnostics: false, + signal, + }); + let decoded: unknown; + try { + if (typeof raw !== "string") throw new TypeError("Expected JSON text"); + decoded = IntrinsicJSONParse(raw); + } catch { + throw API_CLIENT_ERROR.create({ + detail: "Veryfront API dependency metadata history contains invalid JSON", + status: 502, + }); + } + // Host data bypasses generic validator plugins and array-copy hooks. + return parseMetadataHistoryResponse(decoded, expectedProjectId, branch); + } + async listBranchFiles( projectRef: string, branchRef = "main", diff --git a/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts b/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts index 439f71950c..e1cabcbaf5 100644 --- a/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts +++ b/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts @@ -360,6 +360,11 @@ export const API_ENDPOINTS = { path: "/projects/{projectRef}", description: "Get project by UUID or slug", }, + readDependencyMetadataHistory: { + method: "GET" as const, + path: "/projects/{projectRef}/dependencies/history?branch={branch}", + description: "Read bounded prior dependency metadata for a project branch", + }, listBranchFiles: { method: "GET" as const, path: "/projects/{projectRef}/files?branch={branchRef}", diff --git a/src/platform/adapters/veryfront-api-transport.ts b/src/platform/adapters/veryfront-api-transport.ts index a03b3a0191..c2417f6fb1 100644 --- a/src/platform/adapters/veryfront-api-transport.ts +++ b/src/platform/adapters/veryfront-api-transport.ts @@ -15,6 +15,7 @@ import { serverLogger } from "#veryfront/utils/logger/logger.ts"; import { sanitizeUrlCredentials, sanitizeUrlForSpan } from "#veryfront/utils/logger/redact.ts"; import { guardedOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; import { + InvalidResponseBodyUtf8Error, JsonStringValueTooLargeError, maximumJsonStringDocumentBytes, readResponseJsonStringBytesWithinLimit, @@ -463,24 +464,6 @@ function cancelResponseReader( } } -async function readResponseChunk( - reader: ReadableStreamDefaultReader, - signal?: AbortSignal, -): Promise> { - if (!signal) return await reader.read(); - signal.throwIfAborted(); - - return await new Promise>((resolve, reject) => { - const onAbort = () => reject(signal.reason); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - - reader.read().then(resolve, reject).finally(() => { - signal.removeEventListener("abort", onAbort); - }); - }); -} - async function readSuccessfulResponseText( response: Response, maxResponseBytes: number, @@ -513,59 +496,28 @@ async function readSuccessfulResponseText( } } - signal?.throwIfAborted(); - const body = response.body; - if (!body) return ""; - - const reader = body.getReader(); - let bytes = new Uint8Array(Math.min(8 * 1024, maxResponseBytes)); - let byteLength = 0; - let completed = false; - let failure: unknown; - try { - while (true) { - const { done, value } = await readResponseChunk(reader, signal); - if (done) { - completed = true; - break; - } - - if (value.byteLength > maxResponseBytes - byteLength) { - throw successfulResponseProtocolError( - `Veryfront API successful response exceeded ${maxResponseBytes} bytes`, - url, - ); - } - - const requiredLength = byteLength + value.byteLength; - if (requiredLength > bytes.byteLength) { - let capacity = bytes.byteLength; - while (capacity < requiredLength) { - capacity = Math.min(maxResponseBytes, Math.max(requiredLength, capacity * 2)); - } - const grown = new Uint8Array(capacity); - grown.set(bytes.subarray(0, byteLength)); - bytes = grown; - } - bytes.set(value, byteLength); - byteLength = requiredLength; + // One lookahead byte distinguishes an exact-limit body from overflow. The + // shared reader captures byte-view and decoder intrinsics before project + // modules can replace them, and cancels without awaiting untrusted cleanup. + const { text, truncated } = await readResponseTextPrefix( + response, + maxResponseBytes + 1, + signal, + { fatalUtf8: true }, + ); + if (truncated) { + throw successfulResponseProtocolError( + `Veryfront API successful response exceeded ${maxResponseBytes} bytes`, + url, + ); } - } catch (error) { - failure = error; - throw error; - } finally { - if (!completed) cancelResponseReader(reader, failure); - reader.releaseLock(); - } - - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, byteLength)); + return text; } catch (cause) { + if (!(cause instanceof InvalidResponseBodyUtf8Error)) throw cause; throw successfulResponseProtocolError( "Veryfront API successful response body is not valid UTF-8", url, - cause, ); } } diff --git a/src/server/handlers/request/module/module-server-handler.test.ts b/src/server/handlers/request/module/module-server-handler.test.ts index fdf053e3ee..e8dcb67cc0 100644 --- a/src/server/handlers/request/module/module-server-handler.test.ts +++ b/src/server/handlers/request/module/module-server-handler.test.ts @@ -14,6 +14,7 @@ import { } from "#veryfront/transforms/esm/package-registry.ts"; import { createHandlerDependencyPinningSource } from "#veryfront/server/handlers/utils/dependency-pinning-source.ts"; import { DEPENDENCY_PINNING_ENV_FLAG } from "#veryfront/release-assets/constants.ts"; +import { DEPENDENCY_PINNING_ROLLOUT_PERCENT_ENV } from "#veryfront/transforms/esm/dependency-pinning-cohort.ts"; import { deleteEnv, getHostEnv, setEnv } from "#veryfront/platform/compat/process.ts"; import { VERSION } from "#veryfront/utils/version.ts"; @@ -34,6 +35,88 @@ describe( await esbuild.stop(); }); + it("serves canonical pinned paths after the pinning flag is rolled back", async () => { + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + const originalRolloutPercent = getHostEnv(DEPENDENCY_PINNING_ROLLOUT_PERCENT_ENV); + const projectDir = "/module-pins-rollback"; + const adapter = createMockAdapter(); + adapter.fs.files.set( + `${projectDir}/package.json`, + JSON.stringify({ dependencies: { react: "19.2.4" } }), + ); + adapter.fs.files.set( + `${projectDir}/page.ts`, + 'export default "rollback-route-canary";\n', + ); + const ctx = { + projectDir, + projectId: "module-pins-rollback", + adapter, + isLocalProject: false, + requestContext: { + token: "", + slug: "module-pins-rollback", + branch: null, + mode: "preview", + }, + securityConfig: null, + } satisfies HandlerContext; + + try { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + setEnv(DEPENDENCY_PINNING_ROLLOUT_PERCENT_ENV, "100"); + const snapshot = await getDependencyPinningSnapshot( + createHandlerDependencyPinningSource(ctx), + ); + assertEquals(snapshot.cacheKey.startsWith("on:"), true); + assertEquals(snapshot.dependencies?.react, "19.2.4"); + + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "0"); + const result = await handleModuleServer( + new Request( + `http://localhost/_vf_modules/_pins/${encodeURIComponent(snapshot.cacheKey)}/page.js`, + ), + ctx, + () => new ResponseBuilder(), + (response): HandlerResult => ({ response, continue: false }), + () => {}, + (error) => error instanceof Error ? error.message : String(error), + ); + + assertEquals(result.response?.status, 200); + assertStringIncludes(await result.response!.text(), "rollback-route-canary"); + } finally { + restoreEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag); + restoreEnv(DEPENDENCY_PINNING_ROLLOUT_PERCENT_ENV, originalRolloutPercent); + } + }); + + it("rejects malformed pinned paths after the pinning flag is rolled back", async () => { + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + try { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "0"); + const result = await handleModuleServer( + new Request("http://localhost/_vf_modules/_pins/%E0%A4%A/page.js"), + { + projectDir: "/module-malformed-pins-rollback", + projectId: "module-malformed-pins-rollback", + adapter: createMockAdapter(), + isLocalProject: false, + securityConfig: null, + }, + () => new ResponseBuilder(), + (response): HandlerResult => ({ response, continue: false }), + () => {}, + (error) => error instanceof Error ? error.message : String(error), + ); + + assertEquals(result.response?.status, 409); + assertEquals(result.response?.headers.get("cache-control"), "no-store"); + } finally { + restoreEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag); + } + }); + it("returns uncached 503 when shared historical storage is unavailable", async () => { const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); try { diff --git a/src/transforms/esm/dependency-metadata-history.test.ts b/src/transforms/esm/dependency-metadata-history.test.ts new file mode 100644 index 0000000000..b5f81f8c91 --- /dev/null +++ b/src/transforms/esm/dependency-metadata-history.test.ts @@ -0,0 +1,179 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + captureDependencyMetadataHistory, + selectCapturedHistoricalDependencySnapshot, + selectHistoricalDependencySnapshot, +} from "./dependency-metadata-history.ts"; +import { + applyConfiguredDependencyOverrides, + DEPENDENCY_SNAPSHOT_MAX_BYTES, + hashDependencyPins, +} from "./dependency-snapshot.ts"; + +const now = 100_000; +const scope = { projectId: "synthetic-project", branch: null }; +const emptyKey = `on:${hashDependencyPins({})}`; +function history(dependencies: Record = {}, expiresAt = now + 60_000) { + return { version: 1, ...scope, entries: [{ dependencies, expiresAt }] }; +} +function select(value: unknown, key = emptyKey) { + return selectHistoricalDependencySnapshot(value, scope, key, undefined, now); +} + +describe("API-derived dependency metadata history", () => { + it("retains only immutable validated fields instead of the provider response", () => { + const source = { ...history({ react: "19.2.4" }), ignored: "x".repeat(2 * 1024 * 1024) }; + const captured = captureDependencyMetadataHistory(source, scope, now); + source.entries[0]!.dependencies.react = "18.3.1"; + source.entries.length = 0; + assertEquals(captured.value.entries[0]?.dependencies.react, "19.2.4"); + assertEquals(Object.hasOwn(captured.value, "ignored"), false); + assertEquals(captured.bytes < 1024, true); + assertEquals(Object.isFrozen(captured.value), true); + assertEquals(Object.isFrozen(captured.value.entries), true); + assertEquals(Object.isFrozen(captured.value.entries[0]), true); + assertEquals(Object.isFrozen(captured.value.entries[0]?.dependencies), true); + }); + + it("rechecks cached scope and expiry without extending acknowledged retention", () => { + const captured = captureDependencyMetadataHistory(history({}, now + 500), scope, now); + assertEquals( + selectCapturedHistoricalDependencySnapshot( + captured.value, + scope, + emptyKey, + undefined, + now, + )?.snapshot.cacheKey, + emptyKey, + ); + assertEquals( + selectCapturedHistoricalDependencySnapshot( + captured.value, + scope, + emptyKey, + undefined, + now + 500, + ), + undefined, + ); + assertThrows(() => + selectCapturedHistoricalDependencySnapshot( + captured.value, + { ...scope, projectId: "another-project" }, + emptyKey, + undefined, + now, + ) + ); + }); + + it("reconstructs the exact prior empty map with its acknowledged expiry", () => { + const result = select(history()); + assertEquals(result?.snapshot.cacheKey, "on:54uvgwr2ih7p"); + assertEquals({ ...result?.snapshot.dependencies }, {}); + assertEquals(result?.expiresAt, now + 60_000); + }); + + it("combines raw history with captured configuration before matching", () => { + const dependencies = { react: "^18", veryfront: "~0.1.1", other: "^2" }; + const config = { + react: { declaration: "^19.2.4", effective: "19.2.4" }, + veryfront: { declaration: "^0.1.1258", effective: "0.1.1258" }, + }; + const effective = applyConfiguredDependencyOverrides(dependencies, config); + const key = `on:${hashDependencyPins(effective, config)}`; + const result = selectHistoricalDependencySnapshot( + history(dependencies), + scope, + key, + config, + now, + ); + assertEquals(result?.snapshot.cacheKey, key); + assertEquals({ ...result?.snapshot.dependencies }, effective); + assertEquals(result?.snapshot.configuredVersions, config); + assertEquals( + selectHistoricalDependencySnapshot(history(dependencies), scope, key, undefined, now), + undefined, + "a config change must not reinterpret the old key", + ); + }); + + it("does not use the newest map when no historical map matches", () => { + assertEquals(select(history({ react: "19.2.4" })), undefined); + assertEquals(select({ version: 1, ...scope, entries: [] }), undefined); + }); + + it("does not resurrect expired history", () => { + assertEquals(select(history({}, now)), undefined); + }); + + for ( + const bad of [ + null, + { ...history(), version: 2 }, + { ...history(), projectId: "another-project" }, + { ...history(), branch: "feature" }, + { ...history(), entries: Array.from({ length: 17 }, () => history().entries[0]) }, + history({}, Number.NaN), + history({}, now + 24 * 60 * 60 * 1000 + 1), + history({}, 1.5), + { ...history(), entries: [{ dependencies: { react: 123 }, expiresAt: now + 1000 }] }, + { ...history(), entries: [{ dependencies: [], expiresAt: now + 1000 }] }, + ] + ) { + it("rejects invalid or cross-scope metadata without a fallback", () => { + assertThrows(() => select(bad), Error); + }); + } + + it("requires the exact named branch", () => { + const branchScope = { ...scope, branch: "feature-one" }; + const value = { ...history(), branch: "feature-one" }; + assertEquals( + selectHistoricalDependencySnapshot(value, branchScope, emptyKey, undefined, now)?.snapshot + .cacheKey, + emptyKey, + ); + assertThrows(() => select(value)); + }); + + it("bounds total UTF-8 metadata before hashing candidate maps", () => { + assertThrows(() => select(history({ huge: "界".repeat(DEPENDENCY_SNAPSHOT_MAX_BYTES / 2) }))); + assertThrows(() => select(history({ huge: "x".repeat(DEPENDENCY_SNAPSHOT_MAX_BYTES + 1) }))); + }); + + it("does not invoke accessors or proxy traps on metadata", () => { + let invoked = false; + const value = history(); + Object.defineProperty(value, "entries", { + get() { + invoked = true; + return []; + }, + }); + assertThrows(() => select(value)); + assertEquals(invoked, false); + const proxy = new Proxy(history(), { + getOwnPropertyDescriptor() { + invoked = true; + return undefined; + }, + }); + assertThrows(() => select(proxy)); + assertEquals(invoked, false); + }); + + it("preserves prototype-shaped dependency names as inert data", () => { + const dependencies = JSON.parse('{"__proto__":"one","constructor":"two","toJSON":"three"}'); + const key = `on:${hashDependencyPins(dependencies)}`; + const result = select(history(dependencies), key); + assertEquals(Object.getPrototypeOf(result?.snapshot.dependencies), null); + assertEquals(result?.snapshot.dependencies?.["__proto__"], "one"); + const constructorKey: string = "constructor"; + assertEquals(result?.snapshot.dependencies?.[constructorKey], "two"); + }); +}); diff --git a/src/transforms/esm/dependency-metadata-history.ts b/src/transforms/esm/dependency-metadata-history.ts new file mode 100644 index 0000000000..378983f5d1 --- /dev/null +++ b/src/transforms/esm/dependency-metadata-history.ts @@ -0,0 +1,179 @@ +import type { DependencyMetadataHistory } from "#veryfront/platform/adapters/dependency-metadata-history.ts"; +import { DEPENDENCY_SNAPSHOT_STORE_UNAVAILABLE } from "#veryfront/errors/error-registry/server.ts"; +import { + canIdentifyProxyWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; +import { + applyConfiguredDependencyOverrides, + createDependencyPinningSnapshot, + DEPENDENCY_SNAPSHOT_MAX_BYTES, + DEPENDENCY_SNAPSHOT_RETENTION_MS, + type DependencyPinningSnapshot, + encodeDependencySnapshot, + hashDependencyPins, +} from "./dependency-snapshot.ts"; + +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectKeys = Object.keys; +const hasOwn = Object.hasOwn; +const getPrototypeOf = Object.getPrototypeOf; +const objectPrototype = Object.prototype; +const createObject = Object.create; +const setPrototypeOf = Object.setPrototypeOf; +const isArray = Array.isArray; +const isSafeInteger = Number.isSafeInteger; +const stringify = JSON.stringify; +const freeze = Object.freeze; + +export interface MetadataHistoryScope { + readonly projectId: string; + readonly branch: string | null; +} + +export interface HistoricalDependencySnapshot { + readonly snapshot: DependencyPinningSnapshot; + readonly expiresAt: number; +} + +function unavailable(): Error { + return DEPENDENCY_SNAPSHOT_STORE_UNAVAILABLE.create(); +} + +function record(value: unknown): Record { + if ( + value === null || typeof value !== "object" || !canIdentifyProxyWithoutHooks || + isProxyWithoutHooks(value) || isArray(value) + ) throw unavailable(); + const prototype = getPrototypeOf(value); + if (prototype !== null && prototype !== objectPrototype) throw unavailable(); + return value as Record; +} + +function own(value: Record | readonly unknown[], name: string): unknown { + const descriptor = getOwnPropertyDescriptor(value, name); + if (!descriptor || !hasOwn(descriptor, "value")) throw unavailable(); + return descriptor.value; +} + +type MetadataEntry = { dependencies: Record; expiresAt: number }; + +function readHistoryEntry(value: unknown, now: number): MetadataEntry { + const entry = record(value); + const expiresAt = own(entry, "expiresAt"); + if ( + typeof expiresAt !== "number" || !isSafeInteger(expiresAt) || expiresAt <= 0 || + expiresAt > now + DEPENDENCY_SNAPSHOT_RETENTION_MS + ) throw unavailable(); + const raw = record(own(entry, "dependencies")); + const dependencies: Record = createObject(null); + const names = objectKeys(raw); + let keyIndex = 0; + while (keyIndex < names.length) { + const name = names[keyIndex++]!; + const declaration = own(raw, name); + if ( + name.length > DEPENDENCY_SNAPSHOT_MAX_BYTES || typeof declaration !== "string" || + declaration.length > DEPENDENCY_SNAPSHOT_MAX_BYTES + ) throw unavailable(); + dependencies[name] = declaration; + } + freeze(dependencies); + return freeze({ __proto__: null, dependencies, expiresAt }) as MetadataEntry; +} + +/** Copy only validated scoped fields before retaining an API history response. */ +export function captureDependencyMetadataHistory( + value: unknown, + scope: MetadataHistoryScope, + now: number, +): { value: DependencyMetadataHistory; bytes: number } { + const history = record(value); + if ( + own(history, "version") !== 1 || own(history, "projectId") !== scope.projectId || + own(history, "branch") !== scope.branch + ) throw unavailable(); + const entries = own(history, "entries"); + if (!isArray(entries) || isProxyWithoutHooks(entries) || entries.length > 16) throw unavailable(); + + const safeEntries: MetadataEntry[] = []; + setPrototypeOf(safeEntries, null); + for (let index = 0; index < entries.length; index++) { + safeEntries[index] = readHistoryEntry(own(entries, `${index}`), now); + } + const safe = { + __proto__: null, + version: 1, + projectId: scope.projectId, + branch: scope.branch, + entries: safeEntries, + }; + const bytes = utf8ByteLength(stringify(safe)); + if (bytes > DEPENDENCY_SNAPSHOT_MAX_BYTES) throw unavailable(); + freeze(safeEntries); + return { value: freeze(safe) as DependencyMetadataHistory, bytes }; +} + +/** + * API history contains prior raw metadata, not renderer configuration or a + * grant to publish snapshots. Only an exact reconstruction for this source + * may recover a historical request, and its original expiry is retained. + */ +export function selectHistoricalDependencySnapshot( + value: unknown, + scope: MetadataHistoryScope, + requestedKey: string, + configuredVersions: DependencyPinningSnapshot["configuredVersions"], + now: number, +): HistoricalDependencySnapshot | undefined { + return selectCapturedHistoricalDependencySnapshot( + captureDependencyMetadataHistory(value, scope, now).value, + scope, + requestedKey, + configuredVersions, + now, + ); +} + +/** Select from the immutable result of captureDependencyMetadataHistory without recopying it. */ +export function selectCapturedHistoricalDependencySnapshot( + value: DependencyMetadataHistory, + scope: MetadataHistoryScope, + requestedKey: string, + configuredVersions: DependencyPinningSnapshot["configuredVersions"], + now: number, +): HistoricalDependencySnapshot | undefined { + const envelope = record(value); + if ( + own(envelope, "version") !== 1 || own(envelope, "projectId") !== scope.projectId || + own(envelope, "branch") !== scope.branch + ) throw unavailable(); + const safeEntries = own(envelope, "entries"); + if (!isArray(safeEntries) || isProxyWithoutHooks(safeEntries)) throw unavailable(); + let selected: HistoricalDependencySnapshot | undefined; + let selectedBytes: string | undefined; + // Index access avoids invoking a mutable Array iterator. + let index = 0; + while (index < safeEntries.length) { + const entry = record(own(safeEntries, `${index++}`)); + const expiresAt = own(entry, "expiresAt"); + if ( + typeof expiresAt !== "number" || !isSafeInteger(expiresAt) || expiresAt <= 0 || + expiresAt > now + DEPENDENCY_SNAPSHOT_RETENTION_MS + ) throw unavailable(); + if (expiresAt <= now) continue; + const dependencies = own(entry, "dependencies") as Readonly>; + const effective = applyConfiguredDependencyOverrides(dependencies, configuredVersions); + const key = `on:${hashDependencyPins(effective, configuredVersions)}`; + if (key !== requestedKey) continue; + const snapshot = createDependencyPinningSnapshot(key, effective, configuredVersions); + const bytes = encodeDependencySnapshot("metadata-history", snapshot); + if (selectedBytes !== undefined && selectedBytes !== bytes) throw unavailable(); + if (!selected || selected.expiresAt < expiresAt) { + selected = { snapshot, expiresAt }; + selectedBytes = bytes; + } + } + return selected; +} diff --git a/src/transforms/esm/dependency-snapshot-registry.test.ts b/src/transforms/esm/dependency-snapshot-registry.test.ts index d0b7712877..5d494a8540 100644 --- a/src/transforms/esm/dependency-snapshot-registry.test.ts +++ b/src/transforms/esm/dependency-snapshot-registry.test.ts @@ -161,6 +161,155 @@ describe("dependency snapshot registry", () => { assertExists(await registry.find("source", a.cacheKey)); assertEquals(registry.peek("source", b.cacheKey), undefined); }); + it("coalesces concurrent metadata history reads by source before selecting a key", async () => { + const registry = new DependencySnapshotRegistry(); + const first = snapshot({ react: "18.3.1" }); + const second = snapshot({ react: "19.2.4" }); + const healthy = snapshot({ zod: "4.0.0" }); + const expiresAt = Date.now() + 60_000; + let reads = 0; + let resolveHistory!: ( + history: ReadonlyArray<{ snapshot: ReturnType; expiresAt: number }>, + ) => void; + const history = new Promise< + ReadonlyArray<{ snapshot: ReturnType; expiresAt: number }> + >((resolve) => resolveHistory = resolve); + const load = () => { + reads++; + return history.then((value) => ({ value, bytes: 100 })); + }; + const select = ( + records: ReadonlyArray<{ snapshot: ReturnType; expiresAt: number }>, + key: string, + ) => records.find((record) => record.snapshot.cacheKey === key); + + const requestedKeys = [ + first.cacheKey, + second.cacheKey, + ...Array.from({ length: 62 }, (_, index) => `on:forged-${index}`), + ]; + const sourceReads = requestedKeys.map((key) => + registry.recoverHistorical( + "source", + key, + load, + (records) => select(records, key), + ) + ); + await Promise.resolve(); + assertEquals(reads, 1); + assertEquals( + await registry.recoverHistorical( + "healthy-source", + healthy.cacheKey, + () => Promise.resolve({ value: { snapshot: healthy, expiresAt }, bytes: 100 }), + (record) => record, + ), + healthy, + ); + resolveHistory([{ snapshot: first, expiresAt }, { snapshot: second, expiresAt }]); + + const recovered = await Promise.all(sourceReads); + assertEquals(recovered[0], first); + assertEquals(recovered[1], second); + assertEquals(recovered.slice(2), Array(62).fill(undefined)); + }); + it("caches settled source history across distinct misses and retains valid keys", async () => { + const registry = new DependencySnapshotRegistry(); + const original = snapshot(); + const record = { snapshot: original, expiresAt: Date.now() + 60_000 }; + let reads = 0; + const load = () => { + reads++; + return Promise.resolve({ value: record, bytes: 100 }); + }; + for (let index = 0; index < 64; index++) { + assertEquals( + await registry.recoverHistorical("source", `on:missing-${index}`, load, () => undefined), + undefined, + ); + } + assertEquals( + await registry.recoverHistorical("source", original.cacheKey, load, (loaded) => loaded), + original, + ); + assertEquals(reads, 1); + }); + + it("refreshes cached history after revision changes, expiry, and clear", async () => { + let now = 1000; + const registry = new DependencySnapshotRegistry({ now: () => now }); + let reads = 0; + const load = () => { + reads++; + return Promise.resolve({ value: null, bytes: 100 }); + }; + const read = (revision = "before") => + registry.recoverHistorical("source", "on:missing", load, () => undefined, revision); + await read(); + await read(); + assertEquals(reads, 1); + await read("after"); + assertEquals(reads, 2); + now += 999; + await read("after"); + assertEquals(reads, 2); + now++; + await read("after"); + assertEquals(reads, 3); + registry.clear(); + await read("after"); + assertEquals(reads, 4); + }); + + it("never extends a snapshot expiry through cached metadata", async () => { + let now = 1000; + const registry = new DependencySnapshotRegistry({ now: () => now }); + const original = snapshot(); + let reads = 0; + const load = () => { + reads++; + return Promise.resolve({ value: { snapshot: original, expiresAt: 1500 }, bytes: 100 }); + }; + const read = () => + registry.recoverHistorical("source", original.cacheKey, load, (value) => value); + assertEquals(await read(), original); + now = 1500; + assertEquals(await read(), undefined); + assertEquals(reads, 1); + }); + + for (const limits of [{ maxEntries: 1 }, { maxBytes: 150 }]) { + it("bounds settled metadata retention by source count and byte budget", async () => { + const registry = new DependencySnapshotRegistry(limits); + let reads = 0; + const load = () => { + reads++; + return Promise.resolve({ value: null, bytes: 100 }); + }; + const read = (source: string) => + registry.recoverHistorical(source, "on:missing", load, () => undefined); + await read("first"); + await read("second"); + await read("first"); + assertEquals(reads, 3); + }); + } + + it("rejects unbounded metadata before retaining it", async () => { + const registry = new DependencySnapshotRegistry(); + for (const bytes of [0, -1, 1.5, Number.NaN, 1024 * 1024 + 1]) { + await assertRejects(() => + registry.recoverHistorical( + "source", + "on:missing", + () => Promise.resolve({ value: null, bytes }), + () => undefined, + ) + ); + } + }); + it("supports store-less standalone operation", async () => { const registry = new DependencySnapshotRegistry(); const original = snapshot(); @@ -177,6 +326,46 @@ describe("dependency snapshot registry", () => { assertEquals(registry.peek("source", a.cacheKey), undefined); assertExists(registry.peek("source", b.cacheKey)); }); + it("aborts cooperative metadata reads and releases every admission slot", async () => { + const registry = new DependencySnapshotRegistry({ timeoutMs: 20 }); + const original = snapshot(); + let aborted = 0; + await Promise.all( + Array.from( + { length: 64 }, + (_, index) => + assertRejects(() => + registry.recoverHistorical( + `source-${index}`, + original.cacheKey, + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted++; + reject(signal.reason); + }, { once: true }); + }), + () => undefined, + ) + ), + ), + ); + assertEquals(aborted, 64); + assertEquals( + await registry.recoverHistorical( + "healthy", + original.cacheKey, + () => + Promise.resolve({ + value: { snapshot: original, expiresAt: Date.now() + 10_000 }, + bytes: 100, + }), + (record) => record, + ), + original, + ); + }); + it("keeps timed-out producers counted until settlement and never publishes late local success", async () => { const release = deferred(); let producers = 0; diff --git a/src/transforms/esm/dependency-snapshot-registry.ts b/src/transforms/esm/dependency-snapshot-registry.ts index fec5266260..04e1fbec67 100644 --- a/src/transforms/esm/dependency-snapshot-registry.ts +++ b/src/transforms/esm/dependency-snapshot-registry.ts @@ -23,6 +23,18 @@ interface PendingOperation { promise: Promise; } +interface MetadataHistoryEntry { + value: unknown; + bytes: number; + revision: string; + expiresAt: number; +} + +const METADATA_HISTORY_CACHE_MS = 1000; +const METADATA_HISTORY_CACHE_SOURCES = 32; +const METADATA_HISTORY_CACHE_BYTES = 8 * 1024 * 1024; +const METADATA_HISTORY_RESPONSE_BYTES = 1024 * 1024; + interface RegistryOptions { store?: DependencySnapshotStore; now?: () => number; @@ -70,6 +82,8 @@ export class DependencySnapshotRegistry { private readonly entries = new NativeMap(); private readonly pending = new NativeMap(); private bytes = 0; + private readonly metadataHistory = new NativeMap(); + private metadataBytes = 0; private generation = 0; readonly #store?: DependencySnapshotStore; private readonly now: () => number; @@ -161,10 +175,71 @@ export class DependencySnapshotRegistry { return snapshot; } + /** + * Retain an exact match reconstructed from API-owned prior metadata, using + * its acknowledged expiry. This read-only path never substitutes for an + * explicitly configured shared store and never publishes renderer state. + * The loader supplies an immutable validated copy and its serialized byte size; + * revision tracks the observed package state, including after flag rollback. + */ + async recoverHistorical( + identity: string, + key: string, + load: (signal: AbortSignal) => Promise<{ value: T; bytes: number }>, + select: ( + loaded: T, + ) => { snapshot: DependencyPinningSnapshot; expiresAt: number } | undefined, + revision = "", + ): Promise { + if (this.#store) return undefined; + const generation = this.generation; + let cached = this.metadataEntry(identity, revision); + if (!cached) { + const loaded = await this.operation(`metadata:${identity}\0${revision}`, undefined, load); + if ( + !isSafeInteger(loaded.bytes) || loaded.bytes <= 0 || + loaded.bytes > METADATA_HISTORY_RESPONSE_BYTES + ) throw this.unavailable(); + cached = { ...loaded, revision, expiresAt: this.now() + METADATA_HISTORY_CACHE_MS }; + if (generation === this.generation) this.insertMetadata(identity, cached); + } + let record: { snapshot: DependencyPinningSnapshot; expiresAt: number } | undefined; + try { + record = select(cached.value as T); + } catch { + throw this.unavailable(); + } + if (!record || record.expiresAt <= this.now()) return undefined; + if ( + record.snapshot.cacheKey !== key || !isSafeInteger(record.expiresAt) || + record.expiresAt > this.now() + DEPENDENCY_SNAPSHOT_RETENTION_MS + ) throw this.unavailable(); + const namespace = await computeHash(identity); + let value: string; + try { + value = encodeDependencySnapshot(namespace, record.snapshot); + } catch { + throw this.unavailable(); + } + const existing = this.entry(`${identity}\0${key}`); + if (existing && existing.value !== value) throw this.unavailable(); + if (generation === this.generation) { + this.insert(`${identity}\0${key}`, { + snapshot: record.snapshot, + value, + expiresAt: record.expiresAt, + bytes: utf8ByteLength(value), + }); + } + return record.snapshot; + } + clear(): void { this.generation++; apply(mapClear, this.entries, []); this.bytes = 0; + apply(mapClear, this.metadataHistory, []); + this.metadataBytes = 0; } private entry(key: string): Entry | undefined { @@ -193,6 +268,41 @@ export class DependencySnapshotRegistry { } } + private metadataEntry(identity: string, revision: string): MetadataHistoryEntry | undefined { + const entry = apply(mapGet, this.metadataHistory, [identity]) as + | MetadataHistoryEntry + | undefined; + if (!entry) return undefined; + apply(mapDelete, this.metadataHistory, [identity]); + if (entry.expiresAt <= this.now() || entry.revision !== revision) { + this.metadataBytes -= entry.bytes; + return undefined; + } + apply(mapSet, this.metadataHistory, [identity, entry]); + return entry; + } + + private insertMetadata(identity: string, entry: MetadataHistoryEntry): void { + const prior = apply(mapGet, this.metadataHistory, [identity]) as + | MetadataHistoryEntry + | undefined; + if (prior) this.metadataBytes -= prior.bytes; + apply(mapDelete, this.metadataHistory, [identity]); + apply(mapSet, this.metadataHistory, [identity, entry]); + this.metadataBytes += entry.bytes; + while ( + apply(mapSize, this.metadataHistory, []) > + Math.min(this.maxEntries, METADATA_HISTORY_CACHE_SOURCES) || + this.metadataBytes > Math.min(this.maxBytes, METADATA_HISTORY_CACHE_BYTES) + ) { + const iterator = apply(mapKeys, this.metadataHistory, []); + const oldest = apply(mapIteratorNext, iterator, []).value as string; + this.metadataBytes -= + (apply(mapGet, this.metadataHistory, [oldest]) as MetadataHistoryEntry).bytes; + apply(mapDelete, this.metadataHistory, [oldest]); + } + } + private async operation( key: string, value: string | undefined, diff --git a/src/transforms/esm/dependency-snapshot.ts b/src/transforms/esm/dependency-snapshot.ts index adcb240365..14134853d3 100644 --- a/src/transforms/esm/dependency-snapshot.ts +++ b/src/transforms/esm/dependency-snapshot.ts @@ -106,6 +106,17 @@ export function hashDependencyPins( })); } +/** Reapply the captured renderer overrides to a raw package dependency map. */ +export function applyConfiguredDependencyOverrides( + dependencies: Readonly>, + configuredVersions?: DependencyPinningSnapshot["configuredVersions"], +): Record { + const effective = copyRecord(dependencies) as Record; + if (configuredVersions?.react) effective.react = configuredVersions.react.effective; + if (configuredVersions?.veryfront) effective.veryfront = configuredVersions.veryfront.effective; + return effective; +} + export function freezeConfiguredVersions( configuredVersions?: DependencyPinningSnapshot["configuredVersions"], ): DependencyPinningSnapshot["configuredVersions"] { diff --git a/src/transforms/esm/package-registry.ts b/src/transforms/esm/package-registry.ts index a2c281b62a..ce0bf4f13b 100644 --- a/src/transforms/esm/package-registry.ts +++ b/src/transforms/esm/package-registry.ts @@ -7,6 +7,11 @@ import { rendererLogger } from "#veryfront/utils"; import { DependencySnapshotRegistry } from "./dependency-snapshot-registry.ts"; import { + captureDependencyMetadataHistory, + selectCapturedHistoricalDependencySnapshot, +} from "./dependency-metadata-history.ts"; +import { + applyConfiguredDependencyOverrides, createDependencyPinningSnapshot, type DependencyPinningSnapshot, freezeConfiguredVersions, @@ -252,13 +257,39 @@ export function createDependencyPinningSource( } : {}), }; + if (adapterFs) captureSourceMetadataHistoryReader(source, adapterFs); if (snapshotStore) snapshotApply(snapshotWeakSet, sourceSnapshotStores, [source, snapshotStore]); return snapshotFreeze(source); } +function captureSourceMetadataHistoryReader( + source: DependencyPinningSource, + adapterFs: FileSystemAdapter, +): void { + const reader = snapshotGetOwnPropertyDescriptor(adapterFs, "readDependencyMetadataHistory"); + if (!reader) return; + if ( + !snapshotHasOwn(reader, "value") || + (reader.value !== undefined && typeof reader.value !== "function") + ) { + throw new TypeError("Dependency metadata history must be a data-property method"); + } + if (reader.value === undefined) return; + const method = reader.value; + snapshotApply(snapshotWeakSet, sourceMetadataHistoryReaders, [ + source, + (signal: AbortSignal) => snapshotApply(method, adapterFs, [signal]) as Promise, + ]); +} + let localSnapshotRegistry = new DependencySnapshotRegistry(); let sharedSnapshotRegistries = new WeakMap(); const sourceSnapshotStores = new WeakMap(); +const sourceMetadataHistoryReaders = new WeakMap< + DependencyPinningSource, + (signal: AbortSignal) => Promise +>(); +const metadataHistoryNow = Date.now; const adapterSnapshotStores = new WeakMap< RuntimeAdapter, { store: DependencySnapshotStore | undefined } @@ -286,6 +317,11 @@ export function withDependencyPinningSourceFileSystem( }); const store = original && snapshotApply(snapshotWeakGet, sourceSnapshotStores, [original]); if (store) snapshotApply(snapshotWeakSet, sourceSnapshotStores, [result, store]); + const historyReader = original && + snapshotApply(snapshotWeakGet, sourceMetadataHistoryReaders, [original]); + if (historyReader) { + snapshotApply(snapshotWeakSet, sourceMetadataHistoryReaders, [result, historyReader]); + } return result; } @@ -583,7 +619,52 @@ export async function resolveRequestedDependencyPinningSnapshot( if (!requestedCacheKey || requestedCacheKey === current.cacheKey) { return current; } - return getDependencyPinningSnapshotSync(source, requestedCacheKey); + const remembered = getDependencyPinningSnapshotSync(source, requestedCacheKey); + if (remembered) return remembered; + if (typeof source !== "object" || source === null) { + return undefined; + } + const target = source.dependencyWritebackTarget; + const reader = snapshotApply(snapshotWeakGet, sourceMetadataHistoryReaders, [source]) as + | ((signal: AbortSignal) => Promise) + | undefined; + if (!reader || !source.projectId || !target || source.releaseId) return undefined; + const scope = { + projectId: source.projectId, + branch: target.kind === "branch" ? target.branch : null, + }; + // A rollout rollback stops new pinning, not reads of still-valid old keys. + // The off snapshot carries no config, so capture this source's overrides + // before awaiting history and retain exact-key reconstruction on rollback. + const configuredVersions = current.cacheKey === "off" + ? freezeConfiguredVersions(captureConfiguredVersions(source.config)) + : current.configuredVersions; + let historyRevision = current.cacheKey; + if (historyRevision === "off") { + // A rollback must still observe package writes before reusing a cached miss. + // Reading raw pins here grants no publication or current-snapshot authority. + const metadata = await readProjectDependencyVersionsWithMode(source, true); + if (metadata.dependencyState === "unknown") return undefined; + historyRevision = `off:${hashDependencyPins(metadata.dependencies ?? {}, configuredVersions)}`; + } + // The handler derives target and source identity from the same resolved + // request branch. An adapter bound to another branch must fail closed here; + // never adopt a response's scope to make an inconsistent source recover. + return await snapshotRegistry(source).recoverHistorical( + snapshotHistoryIdentity(source), + requestedCacheKey, + async (signal) => + captureDependencyMetadataHistory(await reader(signal), scope, metadataHistoryNow()), + (history) => + selectCapturedHistoricalDependencySnapshot( + history, + scope, + requestedCacheKey, + configuredVersions, + metadataHistoryNow(), + ), + historyRevision, + ); } /** @@ -764,10 +845,19 @@ function parsePackageDependencyMap(content: string): Record { export async function readProjectDependencyVersions( source: DependencyPinningSourceInput, +): Promise { + return await readProjectDependencyVersionsWithMode( + source, + getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1", + ); +} + +async function readProjectDependencyVersionsWithMode( + source: DependencyPinningSourceInput, + pinningOn: boolean, ): Promise { const normalized = normalizeDependencyPinningSource(source); if (!normalized.packageJsonPath) return { dependencyState: "absent" }; - const pinningOn = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1"; const pendingKey = `${normalized.cacheIdentity}\0${pinningOn ? "on" : "off"}`; const pending = pendingDependencyVersionReads.get(pendingKey); if (pending) return pending; @@ -855,9 +945,9 @@ async function readProjectDependencyVersionsUncoalesced( const react = deps.react ? normalizeReactVersion(stripSemverRange(deps.react)) : undefined; const veryfront = deps.veryfront ? stripSemverRange(deps.veryfront) : undefined; - // Only materialize the full dependency map when the pinning flag is on. - // Flag-off callers only need react/veryfront extraction; building the full - // map on the default path wastes memory across up to 256 cached projects. + // Full maps are needed for pinning captures and historical revision checks. + // Ordinary flag-off callers only need react/veryfront extraction; retaining + // a full map on that default path wastes memory across 256 cached projects. let dependencies: Record | undefined; let dependencyPinHash: string | undefined; if (pinningOn) { @@ -976,20 +1066,6 @@ function captureConfiguredVersions( }; } -function applyConfiguredDependencyOverrides( - dependencies: Readonly>, - configuredVersions?: DependencyPinningSnapshot["configuredVersions"], -): Record { - const effective = copyDependencyMap(dependencies); - if (configuredVersions?.react) { - effective.react = configuredVersions.react.effective; - } - if (configuredVersions?.veryfront) { - effective.veryfront = configuredVersions.veryfront.effective; - } - return effective; -} - function hasEnabledDependencySnapshot( options: ProjectPackageVersionOptions, ): boolean { diff --git a/tests/integration/semantic-unit-boundary/src/platform/adapters/fs/veryfront/dependency-metadata-history.test.ts b/tests/integration/semantic-unit-boundary/src/platform/adapters/fs/veryfront/dependency-metadata-history.test.ts new file mode 100644 index 0000000000..3b99a58fba --- /dev/null +++ b/tests/integration/semantic-unit-boundary/src/platform/adapters/fs/veryfront/dependency-metadata-history.test.ts @@ -0,0 +1,362 @@ +import "#veryfront/schemas/_test-setup.ts"; + +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { installMockFetch, restoreMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { VeryfrontFSAdapter } from "#veryfront/platform/adapters/fs/veryfront/adapter.ts"; +import { MultiProjectFSAdapter } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; + +const PROJECT_ID = "10000000-1000-4000-8000-100000000001"; + +async function rejectIfPendingAfter(promise: Promise, timeoutMs = 100): Promise { + let timeout: number | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("History reader did not settle promptly after cancellation")), + timeoutMs, + ); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +function createAdapter(): VeryfrontFSAdapter { + return new VeryfrontFSAdapter({ + veryfront: { + apiBaseUrl: "https://api.example.com", + apiToken: "project-token", + projectSlug: "project-slug", + projectId: PROJECT_ID, + cache: { enabled: false }, + }, + }); +} + +describe("VeryfrontFSAdapter dependency metadata history", () => { + it("reads the exact active branch scope", async () => { + const adapter = createAdapter(); + adapter.setContentContext({ + sourceType: "branch", + projectSlug: "project-slug", + branch: "feature/exact", + }); + const calls: Array<{ branch: string | null; signal?: AbortSignal }> = []; + const controller = new AbortController(); + const history = { + version: 1 as const, + projectId: PROJECT_ID, + branch: "feature/exact", + entries: [] as const, + }; + adapter.getClient().readDependencyMetadataHistory = (branch, signal) => { + calls.push({ branch, signal }); + return Promise.resolve(history); + }; + + assertEquals(await adapter.readDependencyMetadataHistory(controller.signal), history); + assertEquals(calls, [{ branch: "feature/exact", signal: controller.signal }]); + }); + + it("reads a request-level branch override instead of the adapter main branch", async () => { + const adapter = createAdapter(); + adapter.setContentContext({ + sourceType: "branch", + projectSlug: "project-slug", + branch: "main", + }); + adapter.setRequestBranch("feature/exact"); + const calls: Array = []; + adapter.getClient().readDependencyMetadataHistory = (branch) => { + calls.push(branch); + return Promise.resolve({ version: 1, projectId: PROJECT_ID, branch, entries: [] }); + }; + assertEquals((await adapter.readDependencyMetadataHistory()).branch, "feature/exact"); + assertEquals(calls, ["feature/exact"]); + adapter.dispose(); + }); + + it("stops waiting for direct adapter initialization without starting history transport", async () => { + const adapter = createAdapter(); + adapter.setContentContext({ + sourceType: "branch", + projectSlug: "project-slug", + branch: "feature/exact", + }); + let releaseInitialization!: () => void; + const initialization = new Promise((resolve) => { + releaseInitialization = resolve; + }); + let markInitializationStarted!: () => void; + const initializationStarted = new Promise((resolve) => { + markInitializationStarted = resolve; + }); + let historyCalls = 0; + adapter.getClient().initialize = () => { + markInitializationStarted(); + return initialization; + }; + adapter.getClient().readDependencyMetadataHistory = () => { + historyCalls++; + return Promise.resolve({ version: 1, projectId: PROJECT_ID, branch: null, entries: [] }); + }; + const controller = new AbortController(); + const cancellation = new Error("direct initialization wait cancelled"); + const history = adapter.readDependencyMetadataHistory(controller.signal); + await initializationStarted; + + controller.abort(cancellation); + + try { + await assertRejects( + () => rejectIfPendingAfter(history), + Error, + "direct initialization wait cancelled", + ); + assertEquals(historyCalls, 0); + } finally { + releaseInitialization(); + await initialization; + await Promise.resolve(); + assertEquals(historyCalls, 0); + adapter.dispose(); + } + }); + + it("isolates concurrent multi-project and branch request contexts", async () => { + const adapter = new MultiProjectFSAdapter({ + veryfront: { + apiBaseUrl: "https://api.example.com", + proxyMode: true, + cache: { enabled: false }, + }, + }); + const requests: Array<{ project: string; branch: string | null; authorization: string }> = []; + installMockFetch(async (input, init) => { + const url = new URL(String(input)); + const project = url.pathname.split("/")[2]; + assertExists(project); + const branch = url.searchParams.get("branch"); + const projectId = project === "project-a" + ? "10000000-1000-4000-8000-100000000001" + : "20000000-2000-4000-8000-200000000002"; + if (url.pathname === `/projects/${project}`) { + return Response.json({ id: projectId, name: project, slug: project }); + } + if (url.pathname === `/projects/${project}/files`) { + return Response.json({ + data: [], + page_info: { self: null, first: null, next: null, prev: null }, + }); + } + requests.push({ + project, + branch, + authorization: + new Headers(init && "headers" in init ? init.headers : undefined).get("authorization") ?? + "", + }); + await new Promise((resolve) => setTimeout(resolve, project === "project-a" ? 10 : 0)); + return Response.json({ + version: 1, + project_id: projectId, + branch, + entries: [], + }); + }); + + try { + const [a, b] = await Promise.all([ + adapter.runWithContext( + "project-a", + "token-a", + () => adapter.readDependencyMetadataHistory(), + "10000000-1000-4000-8000-100000000001", + { branch: "branch-a" }, + ), + adapter.runWithContext( + "project-b", + "token-b", + () => adapter.readDependencyMetadataHistory(), + "20000000-2000-4000-8000-200000000002", + { branch: "branch-b" }, + ), + ]); + + assertEquals(a.projectId, "10000000-1000-4000-8000-100000000001"); + assertEquals(a.branch, "branch-a"); + assertEquals(b.projectId, "20000000-2000-4000-8000-200000000002"); + assertEquals(b.branch, "branch-b"); + assertEquals(requests.sort((left, right) => left.project.localeCompare(right.project)), [ + { project: "project-a", branch: "branch-a", authorization: "Bearer token-a" }, + { project: "project-b", branch: "branch-b", authorization: "Bearer token-b" }, + ]); + } finally { + restoreMockFetch(); + adapter.dispose(); + } + }); + + it("forwards cancellation through the multi-project adapter", async () => { + const adapter = new MultiProjectFSAdapter({ + veryfront: { + apiBaseUrl: "https://api.example.com", + proxyMode: true, + cache: { enabled: false }, + }, + }); + const controller = new AbortController(); + const cancellation = new DOMException("multi-project history cancelled", "AbortError"); + let observedSignal: AbortSignal | null | undefined; + let markHistoryStarted!: () => void; + const historyStarted = new Promise((resolve) => { + markHistoryStarted = resolve; + }); + installMockFetch((input, init) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/dependencies/history")) { + observedSignal = init && "signal" in init ? init.signal : undefined; + markHistoryStarted(); + return new Promise((_resolve, reject) => { + const rejectAbort = () => reject(observedSignal?.reason); + if (observedSignal?.aborted) rejectAbort(); + else observedSignal?.addEventListener("abort", rejectAbort, { once: true }); + }); + } + if (url.pathname.endsWith("/files")) { + return Promise.resolve(Response.json({ + data: [], + page_info: { self: null, first: null, next: null, prev: null }, + })); + } + if (url.pathname.startsWith("/projects/")) { + return Promise.resolve(Response.json({ + id: PROJECT_ID, + name: "project-a", + slug: "project-a", + })); + } + throw new Error(`Unexpected request path: ${url.pathname}`); + }); + + try { + const history = adapter.runWithContext( + "project-a", + "token-a", + () => adapter.readDependencyMetadataHistory(controller.signal), + PROJECT_ID, + { branch: "feature/exact" }, + ); + await historyStarted; + + assertEquals(observedSignal?.aborted, false); + controller.abort(cancellation); + assertEquals(observedSignal?.aborted, true); + await assertRejects(() => history, DOMException, "multi-project history cancelled"); + } finally { + restoreMockFetch(); + adapter.dispose(); + } + }); + + it("stops waiting for multi-project initialization without starting history transport", async () => { + const adapter = new MultiProjectFSAdapter({ + veryfront: { + apiBaseUrl: "https://api.example.com", + proxyMode: true, + cache: { enabled: false }, + }, + }); + let releaseInitialization!: () => void; + const initialization = new Promise((resolve) => { + releaseInitialization = resolve; + }); + let markInitializationStarted!: () => void; + const initializationStarted = new Promise((resolve) => { + markInitializationStarted = resolve; + }); + let historyCalls = 0; + installMockFetch(async (input) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/dependencies/history")) { + historyCalls++; + return Response.json({ + version: 1, + project_id: PROJECT_ID, + branch: "feature/exact", + entries: [], + }); + } + if (url.pathname.endsWith("/files")) { + return Response.json({ + data: [], + page_info: { self: null, first: null, next: null, prev: null }, + }); + } + markInitializationStarted(); + await initialization; + return Response.json({ id: PROJECT_ID, name: "project-a", slug: "project-a" }); + }); + const controller = new AbortController(); + const cancellation = new Error("multi-project initialization wait cancelled"); + const history = adapter.runWithContext( + "project-a", + "token-a", + () => adapter.readDependencyMetadataHistory(controller.signal), + PROJECT_ID, + { branch: "feature/exact" }, + ); + await initializationStarted; + + controller.abort(cancellation); + + try { + await assertRejects( + () => rejectIfPendingAfter(history), + Error, + "multi-project initialization wait cancelled", + ); + assertEquals(historyCalls, 0); + } finally { + releaseInitialization(); + await initialization; + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(historyCalls, 0); + restoreMockFetch(); + adapter.dispose(); + } + }); + + it("rejects immutable release and environment sources without falling back to main", async () => { + for ( + const context of [ + { sourceType: "release" as const, projectSlug: "project-slug", releaseId: "release-id" }, + { + sourceType: "environment" as const, + projectSlug: "project-slug", + environmentName: "production", + releaseId: "release-id", + }, + ] + ) { + const adapter = createAdapter(); + adapter.setContentContext(context); + let called = false; + adapter.getClient().readDependencyMetadataHistory = () => { + called = true; + return Promise.reject(new Error("must not call")); + }; + + await assertRejects( + () => adapter.readDependencyMetadataHistory(), + Error, + "branch sources", + ); + assertEquals(called, false); + } + }); +}); diff --git a/tests/integration/semantic-unit-boundary/src/platform/adapters/veryfront-api-client/dependency-metadata-history.test.ts b/tests/integration/semantic-unit-boundary/src/platform/adapters/veryfront-api-client/dependency-metadata-history.test.ts new file mode 100644 index 0000000000..eabedc4336 --- /dev/null +++ b/tests/integration/semantic-unit-boundary/src/platform/adapters/veryfront-api-client/dependency-metadata-history.test.ts @@ -0,0 +1,450 @@ +import "#veryfront/schemas/_test-setup.ts"; + +import { + assertEquals, + assertExists, + assertInstanceOf, + assertRejects, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; +import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { installMockFetch, restoreMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { VeryfrontAPIOperations } from "#veryfront/platform/adapters/veryfront-api-client/operations.ts"; + +const PROJECT_ID = "10000000-1000-4000-8000-100000000001"; + +function createOps(token = "project-token", maxRetries = 0): VeryfrontAPIOperations { + return new VeryfrontAPIOperations( + "https://api.example.com", + token, + { maxRetries, initialDelay: 1, maxDelay: 1 }, + PROJECT_ID, + ); +} + +function response(branch: string | null = null): Record { + return { + version: 1, + project_id: PROJECT_ID, + branch, + entries: [{ dependencies: { react: "19.1.0" }, expires_at: 1_800_000_000_000 }], + }; +} + +describe("dependency metadata history API", () => { + afterEach(() => restoreMockFetch()); + + it("omits the branch query for main and uses the existing bearer token", async () => { + let requestedUrl = ""; + let authorization = ""; + installMockFetch((_input, init) => { + requestedUrl = String(_input); + authorization = + new Headers(init && "headers" in init ? init.headers : undefined).get("authorization") ?? + ""; + return Promise.resolve(Response.json(response())); + }); + + const history = await createOps().readDependencyMetadataHistory( + "project-slug", + PROJECT_ID, + null, + ); + + assertEquals(new URL(requestedUrl).pathname, "/projects/project-slug/dependencies/history"); + assertEquals(new URL(requestedUrl).search, ""); + assertEquals(authorization, "Bearer project-token"); + assertEquals(requestedUrl.includes("/internal/"), false); + assertEquals(history, { + version: 1, + projectId: PROJECT_ID, + branch: null, + entries: [{ dependencies: { react: "19.1.0" }, expiresAt: 1_800_000_000_000 }], + }); + }); + + it("preserves a named branch exactly in the query and response", async () => { + let requestedUrl = ""; + const branch = "Feature/Exact Case"; + installMockFetch((input) => { + requestedUrl = String(input); + return Promise.resolve(Response.json(response(branch))); + }); + + const history = await createOps().readDependencyMetadataHistory( + "project-slug", + PROJECT_ID, + branch, + ); + + assertEquals(new URL(requestedUrl).searchParams.get("branch"), branch); + assertEquals(history.branch, branch); + }); + + it("preserves dependency names that overlap object internals", async () => { + installMockFetch(() => + Promise.resolve( + new Response( + `{"version":1,"project_id":"${PROJECT_ID}","branch":null,"entries":[{"dependencies":{"__proto__":"proto-version","constructor":"constructor-version","toJSON":"json-version"},"expires_at":1800000000000}]}`, + { headers: { "Content-Type": "application/json" } }, + ), + ) + ); + + const history = await createOps().readDependencyMetadataHistory( + "project-slug", + PROJECT_ID, + null, + ); + const entry = history.entries[0]; + assertExists(entry); + const dependencies = entry.dependencies; + + assertEquals(Object.getPrototypeOf(dependencies), null); + assertEquals("hasOwnProperty" in dependencies, false); + assertEquals("valueOf" in dependencies, false); + assertEquals(Object.keys(dependencies).sort(), ["__proto__", "constructor", "toJSON"]); + assertEquals( + Object.getOwnPropertyDescriptor(dependencies, "__proto__")?.value, + "proto-version", + ); + assertEquals( + Object.getOwnPropertyDescriptor(dependencies, "constructor")?.value, + "constructor-version", + ); + assertEquals(Object.getOwnPropertyDescriptor(dependencies, "toJSON")?.value, "json-version"); + }); + + for (const method of ["map", Symbol.iterator] as const) { + it(`does not expose authenticated metadata to a replaced Array ${String(method)}`, async () => { + const marker = "private-history-package"; + const prepared = Response.json({ + ...response(), + entries: [{ dependencies: { [marker]: "1.0.0" }, expires_at: 1_800_000_000_000 }], + }); + installMockFetch(() => Promise.resolve(prepared)); + const ops = createOps(); + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, method); + assertExists(descriptor); + const apply = Reflect.apply; + const some = Array.prototype.some; + const getOwn = Object.getOwnPropertyDescriptor; + const define = Object.defineProperty; + let exposed = false; + const hasMarker = (value: unknown): boolean => { + if (value === marker) return true; + if (value === null || typeof value !== "object") return false; + const dependencies = getOwn(value, "dependencies")?.value; + return dependencies !== null && typeof dependencies === "object" && + getOwn(dependencies, marker)?.value === "1.0.0"; + }; + define(Array.prototype, method, { + ...descriptor, + value: function (this: readonly unknown[], ...args: unknown[]) { + if (apply(some, this, [hasMarker])) exposed = true; + return apply(descriptor.value, this, args); + }, + }); + let history: Awaited>; + try { + history = await ops.readDependencyMetadataHistory("project-slug", PROJECT_ID, null); + } finally { + define(Array.prototype, method, descriptor); + } + assertEquals(exposed, false); + assertEquals(history.entries[0]?.dependencies[marker], "1.0.0"); + }); + } + + for (const method of ["set", "subarray", "decode"] as const) { + it(`keeps authenticated response bytes away from replaced ${method}`, async () => { + const marker = "private-history-package"; + const prepared = Response.json({ + ...response(), + entries: [{ dependencies: { [marker]: "1.0.0" }, expires_at: 1_800_000_000_000 }], + }); + installMockFetch(() => Promise.resolve(prepared)); + const ops = createOps(); + const target = method === "decode" ? TextDecoder.prototype : Uint8Array.prototype; + const descriptor = Object.getOwnPropertyDescriptor(target, method) ?? + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), method); + assertExists(descriptor); + const ownDescriptor = Object.getOwnPropertyDescriptor(target, method); + const apply = Reflect.apply; + const define = Object.defineProperty; + const decode = TextDecoder.prototype.decode; + const decoder = new TextDecoder(); + let exposed = false; + define(target, method, { + ...descriptor, + value: function (this: unknown, ...args: unknown[]) { + const bytes = method === "subarray" ? this : args[0]; + if (bytes instanceof Uint8Array && apply(decode, decoder, [bytes]).includes(marker)) { + exposed = true; + } + return apply(descriptor.value, this, args); + }, + }); + let history: Awaited>; + try { + history = await ops.readDependencyMetadataHistory("project-slug", PROJECT_ID, null); + } finally { + if (ownDescriptor) define(target, method, ownDescriptor); + else Reflect.deleteProperty(target, method); + } + assertEquals(exposed, false); + assertEquals(history.entries[0]?.dependencies[marker], "1.0.0"); + }); + } + + it("never assigns authenticated entries through an inherited numeric setter", async () => { + const marker = "private-history-package"; + const prepared = Response.json({ + ...response(), + entries: [{ dependencies: { [marker]: "1.0.0" }, expires_at: 1_800_000_000_000 }], + }); + installMockFetch(() => Promise.resolve(prepared)); + const ops = createOps(); + const prior = Object.getOwnPropertyDescriptor(Array.prototype, "0"); + const define = Object.defineProperty; + const own = Object.getOwnPropertyDescriptor; + let exposed = false; + define(Array.prototype, "0", { + configurable: true, + set(value: unknown) { + if (value !== null && typeof value === "object") { + const dependencies = own(value, "dependencies")?.value; + if ( + dependencies !== null && typeof dependencies === "object" && + own(dependencies, marker)?.value === "1.0.0" + ) exposed = true; + } + define(this, "0", { value, writable: true, enumerable: true, configurable: true }); + }, + }); + let history: Awaited>; + try { + history = await ops.readDependencyMetadataHistory("project-slug", PROJECT_ID, null); + } finally { + if (prior) define(Array.prototype, "0", prior); + else Reflect.deleteProperty(Array.prototype, "0"); + } + assertEquals(exposed, false); + assertEquals(history.entries[0]?.dependencies[marker], "1.0.0"); + }); + + it("parses authenticated history without calling a replaced JSON parser", async () => { + const marker = "private-history-package"; + const prepared = Response.json({ + ...response(), + entries: [{ dependencies: { [marker]: "1.0.0" }, expires_at: 1_800_000_000_000 }], + }); + installMockFetch(() => Promise.resolve(prepared)); + const ops = createOps(); + const descriptor = Object.getOwnPropertyDescriptor(JSON, "parse"); + assertExists(descriptor); + const apply = Reflect.apply; + const define = Object.defineProperty; + let exposed = false; + define(JSON, "parse", { + ...descriptor, + value: function (...args: unknown[]) { + if (typeof args[0] === "string" && args[0].includes(marker)) exposed = true; + return apply(descriptor.value, JSON, args); + }, + }); + let history: Awaited>; + try { + history = await ops.readDependencyMetadataHistory("project-slug", PROJECT_ID, null); + } finally { + define(JSON, "parse", descriptor); + } + assertEquals(exposed, false); + assertEquals(history.entries[0]?.dependencies[marker], "1.0.0"); + }); + + it("does not attach malformed successful JSON content to diagnostics", async () => { + const marker = "private-history-content"; + installMockFetch(() => Promise.resolve(new Response(marker))); + const error = await assertRejects( + () => createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, null), + Error, + ); + assertInstanceOf(error, Error); + assertEquals(error.message.includes(marker), false); + assertEquals(error.cause, undefined); + }); + + it("cancels a stalled response body through the caller signal", async () => { + let observedSignal: AbortSignal | null | undefined; + let bodyReads = 0; + let bodyCancellations = 0; + let markBodyRead!: () => void; + const bodyRead = new Promise((resolve) => { + markBodyRead = resolve; + }); + installMockFetch((_input, init) => { + observedSignal = init && "signal" in init ? init.signal : undefined; + return Promise.resolve( + new Response( + new ReadableStream({ + pull() { + bodyReads++; + markBodyRead(); + return new Promise(() => {}); + }, + cancel() { + bodyCancellations++; + }, + }), + ), + ); + }); + const controller = new AbortController(); + const cancellation = new Error("history read cancelled"); + const request = createOps("project-token", 2).readDependencyMetadataHistory( + "project-slug", + PROJECT_ID, + null, + controller.signal, + ); + await bodyRead; + + controller.abort(cancellation); + + await assertRejects(() => request, Error, "history read cancelled"); + assertEquals(observedSignal?.aborted, true); + assertEquals(bodyReads, 1); + assertEquals(bodyCancellations, 1); + }); + + it("rejects an already-aborted signal before starting the request", async () => { + let fetchCalls = 0; + installMockFetch(() => { + fetchCalls++; + return Promise.resolve(Response.json(response())); + }); + const controller = new AbortController(); + const cancellation = new DOMException("history read already cancelled", "AbortError"); + controller.abort(cancellation); + + await assertRejects( + () => + createOps().readDependencyMetadataHistory( + "project-slug", + PROJECT_ID, + null, + controller.signal, + ), + DOMException, + "history read already cancelled", + ); + assertEquals(fetchCalls, 0); + }); + + it("normalizes an explicit main branch to the omitted query", async () => { + let requestedUrl = ""; + installMockFetch((input) => { + requestedUrl = String(input); + return Promise.resolve(Response.json(response())); + }); + + await createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, "main"); + + assertEquals(new URL(requestedUrl).searchParams.has("branch"), false); + }); + + it("rejects response identity mismatches", async () => { + installMockFetch(() => + Promise.resolve(Response.json({ + ...response("expected"), + project_id: "20000000-2000-4000-8000-200000000002", + })) + ); + + await assertRejects( + () => createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, "expected"), + Error, + "identity", + ); + + restoreMockFetch(); + installMockFetch(() => Promise.resolve(Response.json(response("other")))); + await assertRejects( + () => createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, "expected"), + Error, + "identity", + ); + }); + + it("rejects malformed history envelopes without echoing private content", async () => { + for ( + const value of [ + null, + [], + {}, + { ...response(), version: 2 }, + { ...response(), project_id: "not-a-project-id" }, + { ...response(), branch: 1 }, + { ...response(), entries: {} }, + { ...response(), entries: [null] }, + { ...response(), entries: [{ dependencies: [], expires_at: 1 }] }, + { ...response(), entries: [{ dependencies: {}, expires_at: -1 }] }, + { ...response(), entries: [{ dependencies: {}, expires_at: Number.MAX_SAFE_INTEGER + 1 }] }, + ] + ) { + installMockFetch(() => Promise.resolve(Response.json(value))); + const error = await assertRejects(() => + createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, null) + ); + assertInstanceOf(error, Error); + assertEquals(error.cause, undefined); + restoreMockFetch(); + } + }); + + it("rejects invalid entries and more than sixteen candidates", async () => { + for ( + const entries of [ + [{ dependencies: { react: 19 }, expires_at: 1_800_000_000_000 }], + Array.from({ length: 17 }, () => ({ dependencies: {}, expires_at: 1_800_000_000_000 })), + [{ dependencies: {}, expires_at: 1.5 }], + ] + ) { + installMockFetch(() => Promise.resolve(Response.json({ ...response(), entries }))); + await assertRejects(() => + createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, null) + ); + restoreMockFetch(); + } + }); + + it("bounds the response body before materializing it", async () => { + const oversized = JSON.stringify({ ...response(), padding: "x".repeat(1024 * 1024) }); + installMockFetch(() => Promise.resolve(new Response(oversized))); + + const error = await assertRejects( + () => createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, null), + Error, + ); + assertStringIncludes(String(error), "exceeded"); + }); + + it("propagates API errors without retaining the response body in diagnostics", async () => { + installMockFetch(() => + Promise.resolve( + new Response('{"credential":"must-not-escape"}', { + status: 403, + statusText: "Forbidden", + }), + ) + ); + + const error = await assertRejects( + () => createOps().readDependencyMetadataHistory("project-slug", PROJECT_ID, null), + Error, + ); + assertStringIncludes(String(error), "403 Forbidden"); + assertEquals(String(error).includes("must-not-escape"), false); + }); +}); diff --git a/tests/integration/semantic-unit-boundary/src/transforms/esm/package-registry-metadata-history.test.ts b/tests/integration/semantic-unit-boundary/src/transforms/esm/package-registry-metadata-history.test.ts new file mode 100644 index 0000000000..78e17a828a --- /dev/null +++ b/tests/integration/semantic-unit-boundary/src/transforms/esm/package-registry-metadata-history.test.ts @@ -0,0 +1,281 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { deleteEnv, getHostEnv, setEnv } from "#veryfront/platform/compat/process.ts"; +import { createHandlerDependencyPinningSource } from "#veryfront/server/handlers/utils/dependency-pinning-source.ts"; +import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; +import { createDependencySnapshotStoreHandle } from "#veryfront/platform/adapters/dependency-snapshot-store.ts"; +import { + clearReactVersionCache, + createDependencyPinningSource, + getDependencyPinningSnapshot, + getProjectDependenciesSync, + isCurrentDependencyPinningSnapshot, + resolveRequestedDependencyPinningSnapshot, + withDependencyPinningSourceFileSystem, +} from "#veryfront/transforms/esm/package-registry.ts"; + +import { hashDependencyPins } from "#veryfront/transforms/esm/dependency-snapshot.ts"; + +const projectId = "00000000-0000-4000-8000-000000000001"; +const emptyKey = "on:54uvgwr2ih7p"; + +describe("package registry metadata history recovery", () => { + let oldFlag: string | undefined; + let oldCohort: string | undefined; + beforeEach(() => { + oldFlag = getHostEnv("VERYFRONT_DEPENDENCY_PINNING"); + oldCohort = getHostEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT"); + setEnv("VERYFRONT_DEPENDENCY_PINNING", "1"); + setEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT", "100"); + clearReactVersionCache(); + }); + afterEach(() => { + if (oldFlag === undefined) deleteEnv("VERYFRONT_DEPENDENCY_PINNING"); + else setEnv("VERYFRONT_DEPENDENCY_PINNING", oldFlag); + if (oldCohort === undefined) deleteEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT"); + else setEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT", oldCohort); + clearReactVersionCache(); + }); + + function fixture() { + const adapter = createMockAdapter(); + const content = '{"dependencies":{"react":"19.2.4"}}'; + adapter.fs.readFile = () => Promise.resolve(content); + adapter.fs.stat = () => + Promise.resolve({ + isFile: true, + isDirectory: false, + isSymlink: false, + size: content.length, + mtime: new Date(1000), + }); + let reads = 0; + const history = { + version: 1 as const, + projectId, + branch: null as string | null, + entries: [{ dependencies: {}, expiresAt: Date.now() + 60_000 }], + }; + adapter.fs.readDependencyMetadataHistory = () => { + reads++; + return Promise.resolve(history); + }; + const options = { + projectDir: "/synthetic-project", + projectId, + isLocalProject: false, + dependencyWritebackTarget: { kind: "main" as const }, + adapter, + }; + return { + adapter, + history, + options, + get reads() { + return reads; + }, + }; + } + + it("recovers the prior map but never grants it current writeback authority", async () => { + const f = fixture(); + const source = createDependencyPinningSource(f.options); + const current = await getDependencyPinningSnapshot(source); + const recovered = await resolveRequestedDependencyPinningSnapshot(source, emptyKey); + assertEquals(recovered?.cacheKey, emptyKey); + assertEquals({ ...recovered?.dependencies }, {}); + assertEquals(isCurrentDependencyPinningSnapshot(source, emptyKey), false); + assertEquals(isCurrentDependencyPinningSnapshot(source, current.cacheKey), true); + assertEquals({ ...getProjectDependenciesSync(source, emptyKey) }, {}); + assertEquals( + (await resolveRequestedDependencyPinningSnapshot(source, emptyKey))?.cacheKey, + emptyKey, + ); + assertEquals( + f.reads, + 1, + "retain recovered data with its existing expiry, not a new publication", + ); + }); + + it("uses the preview request branch for both source identity and history matching", async () => { + const f = fixture(); + f.history.branch = "feature/exact"; + const source = createHandlerDependencyPinningSource({ + projectDir: f.options.projectDir, + projectId, + adapter: f.adapter, + securityConfig: null, + isLocalProject: false, + requestContext: { + slug: "synthetic-project", + token: "", + branch: "feature/exact", + mode: "preview", + }, + }); + assertEquals(source.branch, "feature/exact"); + assertEquals(source.dependencyWritebackTarget, { kind: "branch", branch: "feature/exact" }); + assertEquals( + (await resolveRequestedDependencyPinningSnapshot(source, emptyKey))?.cacheKey, + emptyKey, + ); + assertEquals(f.reads, 1); + }); + + for (const rollback of ["flag", "cohort"] as const) { + it(`recovers an exact old key after ${rollback} rollback without enabling publication`, async () => { + const f = fixture(); + f.adapter.fs.readFile = () => Promise.resolve("{}"); + const source = createDependencyPinningSource({ + ...f.options, + config: { react: { version: "19.1.0" } }, + }); + const original = await getDependencyPinningSnapshot(source); + f.adapter.fs.readFile = () => Promise.resolve('{"dependencies":{"zod":"3.0.0"}}'); + clearReactVersionCache(); + if (rollback === "flag") setEnv("VERYFRONT_DEPENDENCY_PINNING", "0"); + else setEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT", "0"); + assertEquals((await getDependencyPinningSnapshot(source)).cacheKey, "off"); + const recovered = await resolveRequestedDependencyPinningSnapshot(source, original.cacheKey); + assertEquals(recovered?.cacheKey, original.cacheKey); + assertEquals(recovered?.configuredVersions, original.configuredVersions); + assertEquals(f.reads, 1); + assertEquals(isCurrentDependencyPinningSnapshot(source, original.cacheKey), false); + assertEquals((await getDependencyPinningSnapshot(source)).cacheKey, "off"); + }); + } + + for (const rollback of ["flag", "cohort"] as const) { + it(`invalidates a settled miss after a package write during ${rollback} rollback`, async () => { + const f = fixture(); + f.history.entries.length = 0; + const source = createDependencyPinningSource(f.options); + if (rollback === "flag") setEnv("VERYFRONT_DEPENDENCY_PINNING", "0"); + else setEnv("VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT", "0"); + const previous = { react: "19.2.4" }; + const key = `on:${hashDependencyPins(previous)}`; + assertEquals(await resolveRequestedDependencyPinningSnapshot(source, key), undefined); + assertEquals(f.reads, 1); + // The API acknowledges the preimage before changing package.json. + f.history.entries.push({ dependencies: previous, expiresAt: Date.now() + 60_000 }); + f.adapter.fs.readFile = () => Promise.resolve('{"dependencies":{"react":"19.2.5"}}'); + assertEquals((await resolveRequestedDependencyPinningSnapshot(source, key))?.cacheKey, key); + assertEquals(f.reads, 2); + assertEquals((await getDependencyPinningSnapshot(source)).cacheKey, "off"); + assertEquals(isCurrentDependencyPinningSnapshot(source, key), false); + }); + } + + it("passes the registry cancellation signal to the captured history reader", async () => { + const f = fixture(); + const signals: AbortSignal[] = []; + f.adapter.fs.readDependencyMetadataHistory = (signal?: AbortSignal) => { + if (signal) signals.push(signal); + return Promise.resolve(f.history); + }; + const source = createDependencyPinningSource(f.options); + assertEquals( + (await resolveRequestedDependencyPinningSnapshot(source, emptyKey))?.cacheKey, + emptyKey, + ); + assertExists(signals[0]); + assertEquals(signals[0].aborted, false); + }); + + it("does not read metadata when the current snapshot already matches", async () => { + const f = fixture(); + const source = createDependencyPinningSource(f.options); + const current = await getDependencyPinningSnapshot(source); + assertEquals( + (await resolveRequestedDependencyPinningSnapshot(source, current.cacheKey))?.cacheKey, + current.cacheKey, + ); + assertEquals(f.reads, 0); + }); + + it("fails closed on cross-project and cross-branch history", async () => { + for (const field of ["projectId", "branch"] as const) { + clearReactVersionCache(); + const f = fixture(); + f.history[field] = "another-scope"; + const source = createDependencyPinningSource(f.options); + await assertRejects(() => resolveRequestedDependencyPinningSnapshot(source, emptyKey)); + assertEquals(getProjectDependenciesSync(source, emptyKey), undefined); + } + }); + + it("does not turn expired history into fresh local retention", async () => { + const f = fixture(); + f.history.entries[0]!.expiresAt = Date.now() - 1; + const source = createDependencyPinningSource(f.options); + assertEquals(await resolveRequestedDependencyPinningSnapshot(source, emptyKey), undefined); + assertEquals(getProjectDependenciesSync(source, emptyKey), undefined); + }); + + it("captures the original own reader and carries it through tracked filesystems", async () => { + const f = fixture(); + const source = createDependencyPinningSource(f.options); + f.adapter.fs.readDependencyMetadataHistory = () => Promise.reject(new Error("replaced")); + const tracked = withDependencyPinningSourceFileSystem(source, "/tracked", { + readFile: f.adapter.fs.readFile, + stat: f.adapter.fs.stat, + }); + assertEquals( + (await resolveRequestedDependencyPinningSnapshot(tracked, emptyKey))?.cacheKey, + emptyKey, + ); + assertEquals(f.reads, 1); + }); + + it("does not invoke accessors when capturing the reader", () => { + const f = fixture(); + let invoked = false; + Object.defineProperty(f.adapter.fs, "readDependencyMetadataHistory", { + get() { + invoked = true; + throw new Error("must not invoke"); + }, + }); + assertThrows(() => createDependencyPinningSource(f.options)); + assertEquals(invoked, false); + }); + + it("treats an optional undefined capability as absent", async () => { + const f = fixture(); + f.adapter.fs.readDependencyMetadataHistory = undefined; + const source = createDependencyPinningSource(f.options); + assertEquals(await resolveRequestedDependencyPinningSnapshot(source, emptyKey), undefined); + }); + + it("does not fall back from an explicitly configured shared store", async () => { + const f = fixture(); + const source = createDependencyPinningSource({ + ...f.options, + snapshotStore: createDependencySnapshotStoreHandle({ + publish: () => Promise.resolve(), + read: () => Promise.resolve(null), + }), + }); + assertEquals(await resolveRequestedDependencyPinningSnapshot(source, emptyKey), undefined); + assertEquals(f.reads, 0); + }); + + it("never queries mutable history for a release or an unbound source", async () => { + for ( + const patch of [{ releaseId: "immutable-release" }, { dependencyWritebackTarget: undefined }] + ) { + clearReactVersionCache(); + const f = fixture(); + const source = createDependencyPinningSource({ ...f.options, ...patch }); + assertEquals(await resolveRequestedDependencyPinningSnapshot(source, emptyKey), undefined); + assertEquals(f.reads, 0); + } + }); +}); diff --git a/tests/integration/server/dependency-metadata-history-replicas.test.ts b/tests/integration/server/dependency-metadata-history-replicas.test.ts new file mode 100644 index 0000000000..8b179165ab --- /dev/null +++ b/tests/integration/server/dependency-metadata-history-replicas.test.ts @@ -0,0 +1,470 @@ +import { + assert, + assertEquals, + assertExists, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { VeryfrontConfig } from "#veryfront/config"; +import { getDevScripts } from "#veryfront/html/hydration-script-builder/dev-scripts.ts"; +import { + generateProdHydrationModule, + getProdScriptsForPath, +} from "#veryfront/html/hydration-script-builder/prod-scripts.ts"; +import { fileURLToPath } from "node:url"; +import { PROVIDER_EGRESS_DENY_NET } from "../../../scripts/test/suites.ts"; +import { + captureBrowserDiagnostics, + closeChromium, + getBrowserDiagnosticMessages, + launchChromium, +} from "../../_helpers/playwright.ts"; + +interface Replica { + origin: string; + pid: number; +} + +interface ReplicaHarness { + writebacks(): number; + startReplica(): Promise; + request( + origin: string, + path: string, + ): Promise<{ status: number; body: string }>; + close(): Promise; +} + +type HydrationStyle = "inline preview" | "external production"; + +const PROD_RUNTIME_PATH = "/_veryfront/hydration-runtime.1a2b3c4d.js"; + +const REACT_MODULE = ` +const contexts = new WeakMap(); +export class Component {} +export const Children = { toArray(value) { return Array.isArray(value) ? value : [value]; } }; +export function createElement(type, props, ...children) { + return { type, props: props || {}, children }; +} +export function createContext(value) { + const context = { value, Provider: function Provider({ children }) { return children; } }; + contexts.set(context, value); + return context; +} +export function useContext(context) { return contexts.get(context) ?? context.value ?? null; } +export function isValidElement(value) { return Boolean(value && value.type); } +const React = { Component, Children, createElement, createContext, useContext, isValidElement }; +export default React; +`; + +const REACT_DOM_CLIENT_MODULE = ` +function markHydrated() { + document.documentElement.dataset.hydrated = "yes"; + return { render: markHydrated }; +} +export function createRoot() { return { render: markHydrated }; } +export function hydrateRoot() { return markHydrated(); } +`; + +const ROUTER_MODULE = ` +const store = { + subscribe() { return () => {}; }, + getHref() { return location.pathname + location.search + location.hash; }, + notify() {}, + navigate(href) { location.assign(href); return Promise.resolve(); }, + setNavigator() {}, +}; +export function getNavigationStore() { return store; } +export function RouterProvider({ children }) { return children; } +export function useRouter() { return {}; } +`; + +const CONTEXT_MODULE = ` +export function PageContextProvider({ children }) { return children; } +`; + +const PAGE_MODULE = ` +import { useServerRenderContext } from "/historical-framework.js"; +globalThis.__historicalFrameworkLoaded = typeof useServerRenderContext === "function"; +export default function Page() { return null; } +`; + +function javascript(source: string, status = 200): Response { + return new Response(source, { + status, + headers: { "content-type": "application/javascript; charset=utf-8" }, + }); +} + +function scriptsFor(style: HydrationStyle): string { + if (style === "external production") { + return getProdScriptsForPath(PROD_RUNTIME_PATH); + } + return getDevScripts( + "dependency-metadata-history", + { dev: { hmr: false } } as VeryfrontConfig, + undefined, + undefined, + undefined, + { skipDevHMR: true, skipErrorLogger: true, skipDevFlag: true }, + ); +} + +function hydrationDocument(style: HydrationStyle, key: string): string { + return ` + + + + + +
Historical snapshot page
+ + ${scriptsFor(style)} + +`; +} + +function createReplicaHarness(): ReplicaHarness { + let content = '{"dependencies":{}}'; + let version = 1000; + let writebackCount = 0; + const history: Array< + { dependencies: Record; expiresAt: number } + > = []; + const shared = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen() {} }, + async (request) => { + const path = new URL(request.url).pathname; + if (path === "/metadata-history") { + return Response.json({ + version: 1, + projectId: "synthetic-project", + branch: null, + entries: history, + }); + } + if (path !== "/metadata") return new Response(null, { status: 404 }); + if (request.method === "POST") { + const values = await request.json(); + if (JSON.stringify(values) !== '["react"]') { + return new Response(null, { status: 400 }); + } + // Model the API's acknowledged preimage before the source changes. + history.push({ + dependencies: JSON.parse(content).dependencies, + expiresAt: Date.now() + 60_000, + }); + content = '{"dependencies":{"react":"19.2.4"}}'; + version++; + writebackCount++; + } + return Response.json({ content, version }); + }, + ); + const children: Deno.ChildProcess[] = []; + const streams: Promise[] = []; + + async function startReplica(): Promise { + const child = new Deno.Command(Deno.execPath(), { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + args: [ + "run", + "--allow-all", + PROVIDER_EGRESS_DENY_NET, + "--config", + fileURLToPath(new URL("../../../deno.json", import.meta.url)), + fileURLToPath( + new URL( + "./fixtures/dependency-metadata-history-replica.ts", + import.meta.url, + ), + ), + `http://127.0.0.1:${shared.addr.port}`, + ], + clearEnv: true, + env: { + PATH: Deno.env.get("PATH") ?? "", + VERYFRONT_DEPENDENCY_PINNING: "1", + VERYFRONT_DEPENDENCY_PINNING_ROLLOUT_PERCENT: "100", + VF_DISABLE_LRU_INTERVAL: "1", + SENTRY_ENABLED: "false", + LOG_LEVEL: "error", + ...(Deno.env.get("DENO_DIR") ? { DENO_DIR: Deno.env.get("DENO_DIR")! } : {}), + }, + stdout: "piped", + stderr: "piped", + }).spawn(); + children.push(child); + streams.push(child.stderr.pipeTo(new WritableStream({ write() {} }))); + const reader = child.stdout.getReader(); + const deadline = setTimeout(() => { + try { + child.kill("SIGTERM"); + } catch { /* Exited. */ } + }, 30_000); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) throw new Error("Replica exited before ready"); + buffer += new TextDecoder().decode(value); + if (buffer.length > 65_536) { + throw new Error("Replica readiness output exceeded its limit"); + } + for (const line of buffer.split("\n").slice(0, -1)) { + let ready: { port?: unknown }; + try { + ready = JSON.parse(line); + } catch { + continue; + } + if (Number.isInteger(ready.port)) { + return { origin: `http://127.0.0.1:${ready.port}`, pid: child.pid }; + } + } + } + } finally { + clearTimeout(deadline); + reader.releaseLock(); + streams.push(child.stdout.pipeTo(new WritableStream({ write() {} }))); + } + } + + async function request( + origin: string, + path: string, + ): Promise<{ status: number; body: string }> { + const response = await fetch(origin + path, { + signal: AbortSignal.timeout(30_000), + }); + return { status: response.status, body: await response.text() }; + } + + return { + writebacks: () => writebackCount, + startReplica, + request, + async close() { + for (const child of children) { + try { + child.kill("SIGTERM"); + } catch { /* Already exited. */ } + } + await Promise.all(children.map((child) => child.status)); + await Promise.all(streams); + await shared.shutdown(); + }, + }; +} + +async function prepareColdHistoricalReplica(harness: ReplicaHarness): Promise<{ + originalKey: string; + currentKey: string; + cold: Replica; +}> { + const warm = await harness.startReplica(); + const document = await harness.request(warm.origin, "/document"); + assertEquals(document.status, 200); + const originalKey = JSON.parse(document.body).key as string; + assertEquals( + (await harness.request( + warm.origin, + `/module?key=${encodeURIComponent(originalKey)}`, + )) + .status, + 200, + ); + assertEquals((await harness.request(warm.origin, "/writeback")).status, 200); + const cold = await harness.startReplica(); + assertEquals(cold.pid === warm.pid, false); + const current = await harness.request(cold.origin, "/document"); + assertEquals(current.status, 200); + return { + originalKey, + currentKey: JSON.parse(current.body).key as string, + cold, + }; +} + +async function verifyBrowserHydration(style: HydrationStyle): Promise { + const harness = createReplicaHarness(); + let pageServer: ReturnType | undefined; + const browser = await launchChromium(); + assertExists( + browser, + "This integration test requires the installed Playwright Chromium browser", + ); + + try { + const { originalKey, currentKey, cold } = await prepareColdHistoricalReplica(harness); + assertEquals(currentKey === originalKey, false); + assertEquals(harness.writebacks(), 1); + + const requestedModuleUrls: string[] = []; + let documentRequests = 0; + let historicalLeafRequests = 0; + pageServer = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen() {} }, + async (request) => { + const url = new URL(request.url); + if (url.pathname === "/") { + documentRequests++; + return new Response(hydrationDocument(style, originalKey), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + if (url.pathname === PROD_RUNTIME_PATH) { + return javascript(generateProdHydrationModule()); + } + if (url.pathname === "/react.js") return javascript(REACT_MODULE); + if (url.pathname === "/react-dom-client.js") { + return javascript(REACT_DOM_CLIENT_MODULE); + } + if (url.pathname === "/router.js") return javascript(ROUTER_MODULE); + if (url.pathname === "/context.js") return javascript(CONTEXT_MODULE); + if (url.pathname === "/historical-framework.js") { + historicalLeafRequests++; + const result = await harness.request( + cold.origin, + `/module?key=${encodeURIComponent(originalKey)}`, + ); + return javascript(result.body, result.status); + } + if (url.pathname.startsWith("/_vf_modules/")) { + requestedModuleUrls.push(url.href); + return javascript(PAGE_MODULE); + } + return new Response("Not found", { status: 404 }); + }, + ); + + const context = await browser.newContext(); + await context.route("https://esm.sh/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + body: REACT_MODULE, + }); + }); + const page = await context.newPage(); + const diagnostics = captureBrowserDiagnostics(page); + const { port } = pageServer.addr as Deno.NetAddr; + const response = await page.goto(`http://127.0.0.1:${port}/`); + assertEquals(response?.status(), 200); + await page.waitForFunction( + () => + document.documentElement.dataset.hydrated === "yes" && + Reflect.get(globalThis, "__historicalFrameworkLoaded") === true && + typeof Reflect.get(globalThis, "__veryfrontRenderPage") === "function", + undefined, + { timeout: 5_000 }, + ); + await page.waitForLoadState("networkidle"); + + assertEquals( + documentRequests, + 1, + "historical module hydration must not reload the document", + ); + assertEquals(historicalLeafRequests, 1); + assert(requestedModuleUrls.length > 0); + assertEquals( + requestedModuleUrls.every((url) => { + const match = new URL(url).pathname.match( + /^\/_vf_modules\/_pins\/([^/]+)\//, + ); + return match !== null && decodeURIComponent(match[1]!) === originalKey; + }), + true, + "the browser module graph must stay bound to the original document key", + ); + assertEquals( + requestedModuleUrls.some((url) => url.includes(encodeURIComponent(currentKey))), + false, + "the current dependency key must not mix into the historical document graph", + ); + assertEquals(getBrowserDiagnosticMessages(diagnostics), []); + await context.close(); + } finally { + await closeChromium(browser); + if (pageServer) { + await pageServer.shutdown(); + await pageServer.finished; + } + await harness.close(); + } +} + +describe("API-derived dependency metadata across renderer processes", () => { + it("hydrates the original module on warm, cold, and replacement replicas", async () => { + const harness = createReplicaHarness(); + try { + const warm = await harness.startReplica(); + const document = await harness.request(warm.origin, "/document"); + assertEquals(document.status, 200); + const key = JSON.parse(document.body).key; + assertEquals(key, "on:54uvgwr2ih7p"); + assertEquals( + (await harness.request( + warm.origin, + `/module?key=${encodeURIComponent(key)}`, + )).status, + 200, + ); + assertEquals( + (await harness.request(warm.origin, "/writeback")).status, + 200, + ); + assertEquals(harness.writebacks(), 1); + const cold = await harness.startReplica(); + assertEquals(cold.pid === warm.pid, false); + for (const replica of [warm, cold]) { + const module = await harness.request( + replica.origin, + `/module?key=${encodeURIComponent(key)}`, + ); + assertEquals(module.status, 200); + assertStringIncludes(module.body, "useServerRenderContext"); + } + const authority = await harness.request( + cold.origin, + `/authority?key=${encodeURIComponent(key)}`, + ); + assertEquals( + JSON.parse(authority.body).current, + false, + "history cannot authorize writeback", + ); + const replacement = await harness.startReplica(); + assertEquals( + (await harness.request( + replacement.origin, + `/module?key=${encodeURIComponent(key)}`, + )) + .status, + 200, + ); + const current = await harness.request(cold.origin, "/document"); + assertEquals(current.status, 200); + assertEquals(JSON.parse(current.body).key === key, false); + } finally { + await harness.close(); + } + }); + + for (const style of ["inline preview", "external production"] as const) { + it(`hydrates the old-key graph in Chromium with the ${style} runtime`, async () => { + await verifyBrowserHydration(style); + }); + } +}); diff --git a/tests/integration/server/fixtures/dependency-metadata-history-replica.ts b/tests/integration/server/fixtures/dependency-metadata-history-replica.ts new file mode 100644 index 0000000000..fc0a305d77 --- /dev/null +++ b/tests/integration/server/fixtures/dependency-metadata-history-replica.ts @@ -0,0 +1,97 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; +import { + createDependencyPinningSource, + getDependencyPinningSnapshot, + isCurrentDependencyPinningSnapshot, +} from "#veryfront/transforms/esm/package-registry.ts"; +import { resolveDependencyPinForImport } from "#veryfront/transforms/import-rewriter/dependency-resolution.ts"; +import { + _pendingResolutions, + _setDependencyResolutionPosterForTest, +} from "#veryfront/transforms/esm/npm-registry-client.ts"; +import { serveModule } from "#veryfront/modules/server/module-server.ts"; +import { ensureCliBundlerContracts } from "../../../../cli/shared/default-contracts.ts"; + +await ensureCliBundlerContracts(); +const origin = Deno.args[0]!; +const projectDir = `/replica-${Deno.pid}`; +const adapter = createMockAdapter(); +adapter.fs.stat = async () => { + const { content, version } = await (await fetch(`${origin}/metadata`)).json(); + return { + isFile: true, + isDirectory: false, + isSymlink: false, + size: content.length, + mtime: new Date(version), + }; +}; +adapter.fs.readFile = async () => (await (await fetch(`${origin}/metadata`)).json()).content; +adapter.fs.readDependencyMetadataHistory = async () => { + const response = await fetch(`${origin}/metadata-history`); + if (!response.ok) throw new Error("Metadata history read failed"); + return await response.json(); +}; +const source = createDependencyPinningSource({ + projectDir, + projectId: "synthetic-project", + adapter, + isLocalProject: false, + dependencyWritebackTarget: { kind: "main" }, +}); +_setDependencyResolutionPosterForTest(async (_id, specifiers) => { + const response = await fetch(`${origin}/metadata`, { + method: "POST", + body: JSON.stringify(specifiers), + }); + await response.body?.cancel(); + if (!response.ok) throw new Error("Synthetic writeback failed"); +}); +let document: Awaited>; +Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }) => console.log(JSON.stringify({ port })), +}, async (req) => { + const url = new URL(req.url); + if (url.pathname === "/document") { + document = await getDependencyPinningSnapshot(source); + return Response.json({ key: document.cacheKey }); + } + if (url.pathname === "/writeback") { + resolveDependencyPinForImport("react", { + projectId: "synthetic-project", + projectDir, + dependencyPinningSource: source, + dependencyPinningCacheKey: document.cacheKey, + dependencyPinningDependencies: document.dependencies, + }); + await _pendingResolutions(); + return Response.json({ complete: true }); + } + if (url.pathname === "/authority") { + return Response.json({ + current: isCurrentDependencyPinningSnapshot( + source, + url.searchParams.get("key")!, + ), + }); + } + if (url.pathname === "/module") { + const moduleUrl = `http://localhost/_vf_modules/_pins/${ + encodeURIComponent(url.searchParams.get("key")!) + }/_veryfront/react/server-render-context.js`; + return await serveModule(new Request(moduleUrl), { + projectId: "synthetic-project", + projectDir, + adapter, + isLocalProject: false, + isProxyMode: true, + dev: false, + mode: "preview", + dependencyPinningSource: source, + }); + } + return new Response(null, { status: 404 }); +});