diff --git a/src/observability/instruments/build-instruments.ts b/src/observability/instruments/build-instruments.ts index e510a92d1c..60b05ba91c 100644 --- a/src/observability/instruments/build-instruments.ts +++ b/src/observability/instruments/build-instruments.ts @@ -9,6 +9,11 @@ export interface BuildInstruments { buildDuration: Histogram | null; bundleSizeHistogram: Histogram | null; bundleCounter: Counter | null; + dependencyArtifactBuildCounter: Counter | null; + dependencyArtifactBuildDuration: Histogram | null; + dependencyArtifactBuildBytes: Histogram | null; + dependencyArtifactBuildAssetCount: Histogram | null; + dependencyArtifactBuildExternalImportCount: Histogram | null; } export function createBuildInstruments( @@ -32,5 +37,28 @@ export function createBuildInstruments( description: "Total number of bundles created", unit: "bundles", }), + dependencyArtifactBuildCounter: meter.createCounter( + `${prefix}.dependency_artifact.builds`, + { description: "Dependency artifact build lifecycle events", unit: "events" }, + ), + dependencyArtifactBuildDuration: meter.createHistogram( + `${prefix}.dependency_artifact.build.duration`, + { description: "Dependency artifact build duration", unit: "ms" }, + ), + dependencyArtifactBuildBytes: meter.createHistogram( + `${prefix}.dependency_artifact.build.bytes`, + { description: "Dependency artifact build output bytes", unit: "bytes" }, + ), + dependencyArtifactBuildAssetCount: meter.createHistogram( + `${prefix}.dependency_artifact.build.assets`, + { description: "Dependency artifact build asset count", unit: "assets" }, + ), + dependencyArtifactBuildExternalImportCount: meter.createHistogram( + `${prefix}.dependency_artifact.build.external_imports`, + { + description: "Allowed external imports remaining after artifact materialization", + unit: "imports", + }, + ), }; } diff --git a/src/observability/instruments/instruments-factory.ts b/src/observability/instruments/instruments-factory.ts index fa5598952b..9a1a8c3b59 100644 --- a/src/observability/instruments/instruments-factory.ts +++ b/src/observability/instruments/instruments-factory.ts @@ -41,6 +41,11 @@ export function createEmptyInstruments(): MetricsInstruments { buildDuration: null, bundleSizeHistogram: null, bundleCounter: null, + dependencyArtifactBuildCounter: null, + dependencyArtifactBuildDuration: null, + dependencyArtifactBuildBytes: null, + dependencyArtifactBuildAssetCount: null, + dependencyArtifactBuildExternalImportCount: null, dataFetchDuration: null, dataFetchCounter: null, dataFetchErrorCounter: null, diff --git a/src/observability/metrics/index.ts b/src/observability/metrics/index.ts index a0c2c26bda..4a46bc2181 100644 --- a/src/observability/metrics/index.ts +++ b/src/observability/metrics/index.ts @@ -146,6 +146,18 @@ export function recordBundle( getRecorder()?.recordBundle(sizeKb, attributes); } +/** Record one dependency artifact build lifecycle event. */ +export function recordDependencyArtifactBuild(input: { + event: "claim" | "success" | "failure"; + durationMs?: number; + totalBytes?: number; + assetCount?: number; + remainingExternalImportCount?: number; + failureCode?: string; +}): void { + getRecorder()?.recordDependencyArtifactBuild(input); +} + /** Record data fetch. */ export function recordDataFetch( durationMs: number, diff --git a/src/observability/metrics/recorder.test.ts b/src/observability/metrics/recorder.test.ts index 6ffff8c627..9c4571eed5 100644 --- a/src/observability/metrics/recorder.test.ts +++ b/src/observability/metrics/recorder.test.ts @@ -60,6 +60,11 @@ function createMockInstruments(): MetricsInstruments & { _buildDuration: MockHistogram; _bundleSizeHistogram: MockHistogram; _bundleCounter: MockCounter; + _dependencyArtifactBuildCounter: MockCounter; + _dependencyArtifactBuildDuration: MockHistogram; + _dependencyArtifactBuildBytes: MockHistogram; + _dependencyArtifactBuildAssetCount: MockHistogram; + _dependencyArtifactBuildExternalImportCount: MockHistogram; _dataFetchDuration: MockHistogram; _dataFetchCounter: MockCounter; _dataFetchErrorCounter: MockCounter; @@ -87,6 +92,11 @@ function createMockInstruments(): MetricsInstruments & { const buildDuration = createMockHistogram(); const bundleSizeHistogram = createMockHistogram(); const bundleCounter = createMockCounter(); + const dependencyArtifactBuildCounter = createMockCounter(); + const dependencyArtifactBuildDuration = createMockHistogram(); + const dependencyArtifactBuildBytes = createMockHistogram(); + const dependencyArtifactBuildAssetCount = createMockHistogram(); + const dependencyArtifactBuildExternalImportCount = createMockHistogram(); const dataFetchDuration = createMockHistogram(); const dataFetchCounter = createMockCounter(); const dataFetchErrorCounter = createMockCounter(); @@ -116,6 +126,11 @@ function createMockInstruments(): MetricsInstruments & { buildDuration: buildDuration as never, bundleSizeHistogram: bundleSizeHistogram as never, bundleCounter: bundleCounter as never, + dependencyArtifactBuildCounter: dependencyArtifactBuildCounter as never, + dependencyArtifactBuildDuration: dependencyArtifactBuildDuration as never, + dependencyArtifactBuildBytes: dependencyArtifactBuildBytes as never, + dependencyArtifactBuildAssetCount: dependencyArtifactBuildAssetCount as never, + dependencyArtifactBuildExternalImportCount: dependencyArtifactBuildExternalImportCount as never, dataFetchDuration: dataFetchDuration as never, dataFetchCounter: dataFetchCounter as never, dataFetchErrorCounter: dataFetchErrorCounter as never, @@ -164,6 +179,11 @@ function createMockInstruments(): MetricsInstruments & { _buildDuration: buildDuration, _bundleSizeHistogram: bundleSizeHistogram, _bundleCounter: bundleCounter, + _dependencyArtifactBuildCounter: dependencyArtifactBuildCounter, + _dependencyArtifactBuildDuration: dependencyArtifactBuildDuration, + _dependencyArtifactBuildBytes: dependencyArtifactBuildBytes, + _dependencyArtifactBuildAssetCount: dependencyArtifactBuildAssetCount, + _dependencyArtifactBuildExternalImportCount: dependencyArtifactBuildExternalImportCount, _dataFetchDuration: dataFetchDuration, _dataFetchCounter: dataFetchCounter, _dataFetchErrorCounter: dataFetchErrorCounter, @@ -473,6 +493,103 @@ describe("observability/metrics/recorder", () => { }); }); + describe("recordDependencyArtifactBuild", () => { + it("should record lifecycle, output, and remaining external metrics", () => { + recorder.recordDependencyArtifactBuild({ + event: "success", + durationMs: 120, + totalBytes: 2048, + assetCount: 3, + remainingExternalImportCount: 1, + }); + + assertEquals(instruments._dependencyArtifactBuildCounter._value, 1); + assertEquals( + instruments._dependencyArtifactBuildCounter._lastAttributes, + { event: "success" }, + ); + assertEquals(instruments._dependencyArtifactBuildDuration._value, 120); + assertEquals(instruments._dependencyArtifactBuildBytes._value, 2048); + assertEquals(instruments._dependencyArtifactBuildAssetCount._value, 3); + assertEquals( + instruments._dependencyArtifactBuildExternalImportCount._value, + 1, + ); + }); + + it("should label failed builds without recording unavailable output", () => { + recorder.recordDependencyArtifactBuild({ + event: "failure", + durationMs: 40, + failureCode: "dependency_artifact_graph_incomplete", + }); + + assertEquals(instruments._dependencyArtifactBuildCounter._value, 1); + assertEquals( + instruments._dependencyArtifactBuildCounter._lastAttributes, + { + event: "failure", + failure_code: "dependency_artifact_graph_incomplete", + }, + ); + assertEquals(instruments._dependencyArtifactBuildDuration._value, 40); + assertEquals(instruments._dependencyArtifactBuildBytes._value, 0); + assertEquals(instruments._dependencyArtifactBuildAssetCount._value, 0); + }); + + it("normalizes dependency artifact measurements", () => { + recorder.recordDependencyArtifactBuild({ + event: "success", + durationMs: Number.POSITIVE_INFINITY, + totalBytes: Number.MAX_SAFE_INTEGER + 100, + assetCount: 3.9, + remainingExternalImportCount: -1, + }); + + assertEquals(instruments._dependencyArtifactBuildDuration._value, 0); + assertEquals( + instruments._dependencyArtifactBuildBytes._value, + Number.MAX_SAFE_INTEGER, + ); + assertEquals(instruments._dependencyArtifactBuildAssetCount._value, 3); + assertEquals( + instruments._dependencyArtifactBuildExternalImportCount._value, + 0, + ); + }); + + it("isolates dependency artifact builds from telemetry backend failures", () => { + let attemptedWrites = 0; + instruments._dependencyArtifactBuildCounter.add = () => { + attemptedWrites += 1; + throw new Error("counter unavailable"); + }; + for ( + const histogram of [ + instruments._dependencyArtifactBuildDuration, + instruments._dependencyArtifactBuildBytes, + instruments._dependencyArtifactBuildAssetCount, + instruments._dependencyArtifactBuildExternalImportCount, + ] + ) { + histogram.record = () => { + attemptedWrites += 1; + throw new Error("histogram unavailable"); + }; + } + + recorder.recordDependencyArtifactBuild({ + event: "success", + durationMs: 120, + totalBytes: 2048, + assetCount: 3, + remainingExternalImportCount: 1, + }); + + assertEquals(attemptedWrites, 5); + }); + }); + describe("recordDataFetch", () => { it("should record data fetch duration and increment counter", () => { recorder.recordDataFetch(100); @@ -527,6 +644,11 @@ describe("observability/metrics/recorder", () => { buildDuration: null, bundleSizeHistogram: null, bundleCounter: null, + dependencyArtifactBuildCounter: null, + dependencyArtifactBuildDuration: null, + dependencyArtifactBuildBytes: null, + dependencyArtifactBuildAssetCount: null, + dependencyArtifactBuildExternalImportCount: null, dataFetchDuration: null, dataFetchCounter: null, dataFetchErrorCounter: null, @@ -574,10 +696,12 @@ describe("observability/metrics/recorder", () => { nullRecorder.recordRSCError(); nullRecorder.recordBuild(100); nullRecorder.recordBundle(100); + nullRecorder.recordDependencyArtifactBuild({ event: "claim" }); nullRecorder.recordDataFetch(100); nullRecorder.recordDataFetchError(); nullRecorder.recordCorsRejection(); nullRecorder.recordSecurityHeaders(); + nullRecorder.recordError(); }); }); }); diff --git a/src/observability/metrics/recorder.ts b/src/observability/metrics/recorder.ts index 9b1fd040bf..a62a268466 100644 --- a/src/observability/metrics/recorder.ts +++ b/src/observability/metrics/recorder.ts @@ -172,6 +172,59 @@ export class MetricsRecorder { safelyRecord(() => this.instruments.bundleCounter?.add(1, attributes)); } + recordDependencyArtifactBuild( + input: { + event: "claim" | "success" | "failure"; + durationMs?: number; + totalBytes?: number; + assetCount?: number; + remainingExternalImportCount?: number; + failureCode?: string; + }, + ): void { + const attributes = sanitizeTelemetryAttributes({ + event: input.event, + ...(input.failureCode ? { failure_code: input.failureCode } : {}), + }); + safelyRecord(() => this.instruments.dependencyArtifactBuildCounter?.add(1, attributes)); + const durationMs = input.durationMs; + if (durationMs !== undefined) { + safelyRecord(() => + this.instruments.dependencyArtifactBuildDuration?.record( + nonNegativeFiniteMeasure(durationMs), + attributes, + ) + ); + } + const totalBytes = input.totalBytes; + if (totalBytes !== undefined) { + safelyRecord(() => + this.instruments.dependencyArtifactBuildBytes?.record( + nonNegativeSafeInteger(totalBytes), + attributes, + ) + ); + } + const assetCount = input.assetCount; + if (assetCount !== undefined) { + safelyRecord(() => + this.instruments.dependencyArtifactBuildAssetCount?.record( + nonNegativeSafeInteger(assetCount), + attributes, + ) + ); + } + const remainingExternalImportCount = input.remainingExternalImportCount; + if (remainingExternalImportCount !== undefined) { + safelyRecord(() => + this.instruments.dependencyArtifactBuildExternalImportCount?.record( + nonNegativeSafeInteger(remainingExternalImportCount), + attributes, + ) + ); + } + } + recordDataFetch( durationMs: number, attributes?: Record, diff --git a/src/observability/metrics/types.ts b/src/observability/metrics/types.ts index 5b452ce88d..0328f82d7e 100644 --- a/src/observability/metrics/types.ts +++ b/src/observability/metrics/types.ts @@ -71,6 +71,11 @@ export interface MetricsInstruments { buildDuration: Histogram | null; bundleSizeHistogram: Histogram | null; bundleCounter: Counter | null; + dependencyArtifactBuildCounter: Counter | null; + dependencyArtifactBuildDuration: Histogram | null; + dependencyArtifactBuildBytes: Histogram | null; + dependencyArtifactBuildAssetCount: Histogram | null; + dependencyArtifactBuildExternalImportCount: Histogram | null; dataFetchDuration: Histogram | null; dataFetchCounter: Counter | null; diff --git a/src/platform/adapters/veryfront-api-client/client.ts b/src/platform/adapters/veryfront-api-client/client.ts index f93c790382..7279c8e0a0 100644 --- a/src/platform/adapters/veryfront-api-client/client.ts +++ b/src/platform/adapters/veryfront-api-client/client.ts @@ -12,6 +12,10 @@ import { VeryfrontAPIOperations, } from "./operations.ts"; import { API_CLIENT_ERROR, type VeryfrontAPIConfig, VeryfrontError } from "./types.ts"; +import type { + DependencyArtifactBuildResultBody, + DependencyArtifactContentType, +} from "#veryfront/release-assets/dependency-artifact-contracts.ts"; const logger = baseLogger.component("veryfront-api-client"); @@ -427,6 +431,38 @@ export class VeryfrontApiClient { return this.operations.upsertStyleArtifact(projectRef, input); } + // ============================================================================= + // Dependency Artifact Build Operations + // ============================================================================= + + uploadDependencyArtifactAsset( + artifactId: string, + attemptCount: number, + contentHash: string, + contentType: DependencyArtifactContentType, + bytes: Uint8Array, + ) { + return this.operations.uploadDependencyArtifactAsset( + artifactId, + attemptCount, + contentHash, + contentType, + bytes, + ); + } + + reportDependencyArtifactBuildResult( + artifactId: string, + attemptCount: number, + result: DependencyArtifactBuildResultBody, + ) { + return this.operations.reportDependencyArtifactBuildResult( + artifactId, + attemptCount, + result, + ); + } + // ============================================================================= // Release Asset Manifest Operations // ============================================================================= diff --git a/src/platform/adapters/veryfront-api-client/operations.test.ts b/src/platform/adapters/veryfront-api-client/operations.test.ts index cb253ba427..f9b12209f3 100644 --- a/src/platform/adapters/veryfront-api-client/operations.test.ts +++ b/src/platform/adapters/veryfront-api-client/operations.test.ts @@ -988,4 +988,106 @@ describe("VeryfrontAPIOperations", () => { assertEquals(res.state, "ready"); }); }); + + describe("dependency artifact build operations", () => { + it("uploads an attempt asset with raw hash-verified bytes", async () => { + let requestedUrl = ""; + let method = ""; + let contentType = ""; + let body: BodyInit | null | undefined; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(input); + method = init?.method ?? "GET"; + contentType = new Headers(init?.headers).get("content-type") ?? ""; + body = init?.body; + return Promise.resolve( + new Response(JSON.stringify({ stored: true, existed: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }) as typeof fetch; + const bytes = new TextEncoder().encode("export const value = 42;"); + const contentHash = await crypto.subtle.digest("SHA-256", bytes).then((digest) => + [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("") + ); + + const result = await createOps().uploadDependencyArtifactAsset( + "11111111-1111-4111-8111-111111111111", + 2, + contentHash, + "text/javascript", + bytes, + ); + + assertEquals(method, "PUT"); + assertStringIncludes( + requestedUrl, + `/dependency-artifacts/11111111-1111-4111-8111-111111111111/attempts/2/assets/${contentHash}`, + ); + assertEquals(contentType, "text/javascript"); + assertEquals(body, bytes); + assertEquals(result, { stored: true, existed: false }); + }); + + it("rejects a local content hash mismatch before transport", async () => { + let fetchCalls = 0; + globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => { + fetchCalls++; + return Promise.resolve(new Response("{}")); + }) as typeof fetch; + + await assertRejects( + () => + createOps().uploadDependencyArtifactAsset( + "11111111-1111-4111-8111-111111111111", + 2, + "a".repeat(64), + "text/javascript", + new TextEncoder().encode("different"), + ), + Error, + "content hash", + ); + assertEquals(fetchCalls, 0); + }); + + it("reports the complete ready graph to the lease-bound result endpoint", async () => { + let requestedUrl = ""; + let method = ""; + let body: unknown; + stubJsonFetch((url, init) => { + requestedUrl = url; + method = init?.method ?? "GET"; + body = init?.body ? JSON.parse(String(init.body)) : undefined; + return { accepted: true, state: "ready" }; + }); + const hash = "b".repeat(64); + + const result = await createOps().reportDependencyArtifactBuildResult( + "11111111-1111-4111-8111-111111111111", + 2, + { + outcome: "ready", + graph: { + graph_schema_version: 1, + root_content_hash: hash, + assets: [{ + content_hash: hash, + content_type: "text/javascript", + size: 42, + }], + }, + }, + ); + + assertEquals(method, "POST"); + assertStringIncludes( + requestedUrl, + "/dependency-artifacts/11111111-1111-4111-8111-111111111111/attempts/2/result", + ); + assertEquals((body as { outcome: string }).outcome, "ready"); + assertEquals(result, { accepted: true, state: "ready" }); + }); + }); }); diff --git a/src/platform/adapters/veryfront-api-client/operations.ts b/src/platform/adapters/veryfront-api-client/operations.ts index 1492739daa..72448d6bf9 100644 --- a/src/platform/adapters/veryfront-api-client/operations.ts +++ b/src/platform/adapters/veryfront-api-client/operations.ts @@ -1,4 +1,8 @@ -import { logger as baseLogger } from "#veryfront/utils"; +import { computeHashBytes, logger as baseLogger } from "#veryfront/utils"; +import type { + DependencyArtifactBuildResultBody, + DependencyArtifactContentType, +} from "#veryfront/release-assets/dependency-artifact-contracts.ts"; import { createCanonicalVeryfrontApiTransport, type TransportRequestInit, @@ -7,7 +11,11 @@ import { } from "../veryfront-api-transport.ts"; import { API_CLIENT_ERROR, VeryfrontError } from "./types.ts"; import { + type DependencyArtifactAssetUploadResponse, + type DependencyArtifactBuildResultResponse, getBranchFileDetailSchema, + getDependencyArtifactAssetUploadResponseSchema, + getDependencyArtifactBuildResultResponseSchema, getEnvironmentFileDetailSchema, getListBranchFilesResponseSchema, getListEnvironmentFilesResponseSchema, @@ -712,6 +720,60 @@ export class VeryfrontAPIOperations { ); } + // =========================================================================== + // Dependency artifact build operations + // =========================================================================== + + async uploadDependencyArtifactAsset( + artifactId: string, + attemptCount: number, + contentHash: string, + contentType: DependencyArtifactContentType, + bytes: Uint8Array, + ): Promise { + if (await computeHashBytes(bytes) !== contentHash) { + throw API_CLIENT_ERROR.create({ + detail: "Dependency artifact content hash does not match the upload body", + status: 400, + }); + } + const url = `/dependency-artifacts/${ + encodeURIComponent(artifactId) + }/attempts/${attemptCount}/assets/${contentHash}`; + logger.debug("uploadDependencyArtifactAsset", { + attemptCount, + contentHash, + contentType, + size: bytes.byteLength, + }); + const raw = await this.request(url, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: bytes as BodyInit, + }); + return getDependencyArtifactAssetUploadResponseSchema().parse(raw); + } + + async reportDependencyArtifactBuildResult( + artifactId: string, + attemptCount: number, + result: DependencyArtifactBuildResultBody, + ): Promise { + const url = `/dependency-artifacts/${ + encodeURIComponent(artifactId) + }/attempts/${attemptCount}/result`; + logger.debug("reportDependencyArtifactBuildResult", { + attemptCount, + outcome: result.outcome, + }); + const raw = await this.request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(result), + }); + return getDependencyArtifactBuildResultResponseSchema().parse(raw); + } + // =========================================================================== // Release Asset Manifest operations // =========================================================================== 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 c472879b47..439f71950c 100644 --- a/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts +++ b/src/platform/adapters/veryfront-api-client/schemas/api.schema.ts @@ -227,6 +227,20 @@ export const getReleaseAssetUploadResponseSchema = defineSchema((v) => }) ); +export const getDependencyArtifactAssetUploadResponseSchema = defineSchema((v) => + v.object({ + stored: v.literal(true), + existed: v.boolean(), + }) +); + +export const getDependencyArtifactBuildResultResponseSchema = defineSchema((v) => + v.object({ + accepted: v.literal(true), + state: v.enum(["ready", "failed"] as const), + }) +); + export const getReleaseAssetManifestStateResponseSchema = defineSchema((v) => v.object({ state: v.enum( @@ -322,6 +336,12 @@ export type ReleaseAssetManifestBuildResponse = InferSchema< export type ReleaseAssetUploadResponse = InferSchema< ReturnType >; +export type DependencyArtifactAssetUploadResponse = InferSchema< + ReturnType +>; +export type DependencyArtifactBuildResultResponse = InferSchema< + ReturnType +>; export type ReleaseAssetManifestStateResponse = InferSchema< ReturnType >; diff --git a/src/platform/adapters/veryfront-api-client/schemas/index.ts b/src/platform/adapters/veryfront-api-client/schemas/index.ts index b70c3b3938..c129d8b953 100644 --- a/src/platform/adapters/veryfront-api-client/schemas/index.ts +++ b/src/platform/adapters/veryfront-api-client/schemas/index.ts @@ -8,11 +8,15 @@ export { API_ENDPOINTS, type BranchFileDetail, type BranchFileListItem, + type DependencyArtifactAssetUploadResponse, + type DependencyArtifactBuildResultResponse, type Environment, type EnvironmentFileDetail, type EnvironmentFileListItem, getBranchFileDetailSchema, getBranchFileListItemSchema, + getDependencyArtifactAssetUploadResponseSchema, + getDependencyArtifactBuildResultResponseSchema, getEnvironmentFileDetailSchema, getEnvironmentFileListItemSchema, getEnvironmentSchema, diff --git a/src/release-assets/build-executor.test.ts b/src/release-assets/build-executor.test.ts index 0b8130edc0..bc4a1e0af3 100644 --- a/src/release-assets/build-executor.test.ts +++ b/src/release-assets/build-executor.test.ts @@ -960,6 +960,45 @@ describe("release asset build executor", () => { ]); }); + it("keeps final import validation when reusing the dependency materializer", async () => { + enableDependencyImportMap(); + const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] }; + const files = [{ + path: "pages/index.tsx", + content: 'import legacy from "legacy-package"; export default legacy;', + }]; + const client = makeClient(files, rec); + const transform = () => + Promise.resolve( + 'import legacy from "https://esm.sh/legacy-package@1"; export default legacy;', + ); + const legacyCode = [ + "export const load = (path) => import(path);", + 'export const asset = new URL("./worker.wasm", import.meta.url);', + "//# sourceMappingURL=legacy-package.js.map", + ].join("\n"); + const input = { + ...baseInput(client, transform), + vendorHttpImports: withFakeReactVendor((code: string) => + Promise.resolve({ + code: code.replace( + "https://esm.sh/legacy-package@1", + "file:///virtual/veryfront-http-bundle/http-legacy.mjs", + ), + dependencies: [{ + specifier: "file:///virtual/veryfront-http-bundle/http-legacy.mjs", + manifestKey: "https://esm.sh/legacy-package@1", + code: legacyCode, + }], + }) + ), + }; + + const result = await runReleaseAssetBuild(input, await tmp()); + + assertCoverageFailure(result, rec, "dependency-finalize-failed"); + }); + it("rewrites nested vendored HTTP dependency imports to immutable assets", async () => { enableDependencyImportMap(); const rec: Recorded = { began: false, uploads: [], manifest: null, states: [] }; diff --git a/src/release-assets/build-executor.ts b/src/release-assets/build-executor.ts index b5c463cb07..82f0134d36 100644 --- a/src/release-assets/build-executor.ts +++ b/src/release-assets/build-executor.ts @@ -87,6 +87,7 @@ import { type ReleaseAssetRouteEntry, } from "./manifest-schema.ts"; import type { CompileProjectCssResult } from "./css-compile.ts"; +import { materializeReleaseDependencyGraph } from "./dependency-artifact-graph.ts"; const logger = serverLogger.component("release-asset-build"); @@ -1599,9 +1600,7 @@ async function finalizeDependencyModules( const finalized = new Map(); const fallbackUrls = new Map(); - const skippedCycles = new Set(); const recordedCycleGaps = new Set(); - const visiting: string[] = []; function resolveDependencyImport( specifier: string, @@ -1616,11 +1615,9 @@ async function finalizeDependencyModules( return bySpecifier.get(normalizeDependencySpecifier(specifier)) ?? null; } - function recordDependencyCycle(cycleKeys: string[]): void { + function recordDependencyCycle(cycleKeys: readonly string[]): void { // Separate content-hashed ESM files cannot represent cyclic imports without // release-scoped aliases or bundling, so keep only that component on source URL fallback. - for (const key of cycleKeys) skippedCycles.add(key); - const gap = `dependency-cycle:${cycleKeys.join("->")}`; if (!recordedCycleGaps.has(gap)) { recordedCycleGaps.add(gap); @@ -1628,89 +1625,66 @@ async function finalizeDependencyModules( } } - function cycleFallbackFor(dependency: DependencyModule): string | null { - if (!skippedCycles.has(dependency.manifestKey)) return null; - return dependencyFallbackUrl(dependency); - } - - async function finalize(manifestKey: string): Promise { - const existing = finalized.get(manifestKey); - if (existing) return existing; - if (visiting.includes(manifestKey)) { - recordDependencyCycle([...visiting.slice(visiting.indexOf(manifestKey)), manifestKey]); - return null; - } - if (skippedCycles.has(manifestKey)) return null; - - const dependency = dependencyModules.get(manifestKey); - if (!dependency) return null; - - visiting.push(manifestKey); - try { - const imports = await parseImports(dependency.code); - for (const imp of imports) { - if (!imp.n) continue; - const filePath = resolveLocalDependencyPath(imp.n, dependency.sourcePath); - if (filePath && !byFilePath.has(filePath)) { - throw new Error(`Unresolved vendored file dependency: ${imp.n}`); - } - const child = resolveDependencyImport(imp.n, dependency); - if (!child) continue; - if (child.manifestKey === manifestKey) { - recordDependencyCycle([manifestKey, manifestKey]); - continue; - } - await finalize(child.manifestKey); - } - - if (skippedCycles.has(manifestKey)) return null; - - const rewritten = await replaceSpecifiers(dependency.code, (specifier) => { - const child = resolveDependencyImport(specifier, dependency); - if (!child) return null; - const asset = finalized.get(child.manifestKey); - if (asset) return releaseAssetUrl(asset.contentHash, "js"); - return cycleFallbackFor(child); - }); - await assertFinalModuleImports(rewritten, { allowHttp: false }); - - const entry = await addPreparedJavaScriptAsset( - `__dependencies__/${manifestKey}`, - rewritten, - uploadQueue, - pendingBytes, - ); - if (!entry) { - throw new Error(`Vendored dependency exceeds release asset size limit: ${manifestKey}`); + const graphModules = new Map( + [...dependencyModules].map(([manifestKey, dependency]) => + [ + manifestKey, + { + id: manifestKey, + code: dependency.code, + contentType: RELEASE_ASSET_CONTENT_TYPES.js, + }, + ] as const + ), + ); + const materialized = await materializeReleaseDependencyGraph({ + modules: graphModules, + maxAssetBytes: RELEASE_ASSET_MAX_SIZE_BYTES, + resolveImport(specifier, parent) { + const dependency = dependencyModules.get(parent.id); + if (!dependency) return { kind: "invalid", failureCode: "graph_incomplete" }; + const filePath = resolveLocalDependencyPath(specifier, dependency.sourcePath); + if (filePath && !byFilePath.has(filePath)) { + throw new Error(`Unresolved vendored file dependency: ${specifier}`); } - for (const specifier of dependency.specifiers) { - setDependencyModuleAlias( - bySpecifier, - normalizeDependencySpecifier(specifier), - dependency, - ); + const child = resolveDependencyImport(specifier, dependency); + return child ? { kind: "module", moduleId: child.manifestKey } : { kind: "external" }; + }, + cycleFallbackUrl(module) { + const dependency = dependencyModules.get(module.id); + const fallbackUrl = dependency ? dependencyFallbackUrl(dependency) : null; + if (!fallbackUrl) { + throw new Error(`Unrepresentable vendored dependency cycle: ${module.id}`); } - finalized.set(manifestKey, entry); - return entry; - } finally { - visiting.pop(); - } - } + return fallbackUrl; + }, + onCycle: recordDependencyCycle, + assetSizeErrorMessage: (module) => + `Vendored dependency exceeds release asset size limit: ${module.id}`, + }); - try { - for (const manifestKey of dependencyModules.keys()) await finalize(manifestKey); - } finally { - visiting.length = 0; + for (const asset of materialized.assets) { + await assertFinalModuleImports(new TextDecoder().decode(asset.bytes), { + allowHttp: false, + }); + const entry: PreparedAsset = { + logicalPath: `__dependencies__/${asset.sourceId}`, + contentHash: asset.contentHash, + size: asset.size, + contentType: asset.contentType, + }; + finalized.set(asset.sourceId, entry); + if (rememberPendingAsset(pendingBytes, entry, asset.bytes)) { + uploadQueue.push(entry); + } } - for (const manifestKey of skippedCycles) { + for (const manifestKey of materialized.skippedCycleIds) { const dependency = dependencyModules.get(manifestKey); if (!dependency) continue; const fallbackUrl = dependencyFallbackUrl(dependency); - if (!fallbackUrl) { - throw new Error(`Unrepresentable vendored dependency cycle: ${manifestKey}`); - } + if (!fallbackUrl) throw new Error(`Unrepresentable vendored dependency cycle: ${manifestKey}`); addDependencyUrlAliases(fallbackUrls, dependency, fallbackUrl); } diff --git a/src/release-assets/dependency-artifact-builder.test.ts b/src/release-assets/dependency-artifact-builder.test.ts new file mode 100644 index 0000000000..badd7230d5 --- /dev/null +++ b/src/release-assets/dependency-artifact-builder.test.ts @@ -0,0 +1,621 @@ +import "#veryfront/schemas/_test-setup.ts"; + +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { computeHashBytes } from "#veryfront/utils"; +import { + buildDependencyArtifactGraph, + type DependencyArtifactBuildClient, + type DependencyArtifactBuildTaskInput, + dependencyArtifactUpstreamUrl, + parseDependencyArtifactBuildTaskInput, + runDependencyArtifactBuild, +} from "./dependency-artifact-builder.ts"; +import { materializeDependencyArtifactGraph } from "./dependency-artifact-graph.ts"; + +const encoder = new TextEncoder(); + +const standardIdentity = { + origin_key: "npm:public", + package_name: "fixture-package", + exact_version: "1.2.3", + subpath: "feature", + target: "es2022", + profile: "standard-v1", +} as const; + +function buildTaskInput( + overrides: Partial = {}, +): DependencyArtifactBuildTaskInput { + return { + artifact_id: "11111111-1111-4111-8111-111111111111", + attempt_count: 1, + identity: standardIdentity, + policy: { decision: "allow" }, + ...overrides, + }; +} + +function response(body: string, contentType = "text/javascript"): Response { + return new Response(body, { + status: 200, + headers: { "content-type": contentType }, + }); +} + +function fixtureFetch( + fixtures: Record Promise)>, + calls: string[] = [], +): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push(url); + if (init?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const fixture = fixtures[url]; + if (!fixture) return new Response("missing", { status: 404 }); + return typeof fixture === "function" ? await fixture() : fixture; + }) as typeof fetch; +} + +async function settleBeforeWatchdog(promise: Promise): Promise { + let timeout: ReturnType | undefined; + const watchdog = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Dependency artifact timeout did not settle promptly")), + 1_000, + ); + }); + try { + return await Promise.race([promise, watchdog]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +function recordingClient() { + const events: Array> = []; + const client: DependencyArtifactBuildClient = { + async uploadAsset(input) { + events.push({ kind: "upload", ...input }); + return { stored: true, existed: false }; + }, + async reportResult(input) { + events.push({ kind: "result", ...input }); + return { + accepted: true, + state: input.result.outcome === "ready" ? "ready" : "failed", + }; + }, + }; + return { client, events }; +} + +function failureCodeOf( + result: Awaited>, +): string { + if (result.success) throw new Error("expected dependency artifact build failure"); + return result.failureCode; +} + +describe("release-assets/dependency-artifact-builder", () => { + it("constructs and fetches exact public npm URLs for scoped package subpaths", async () => { + const identity = { + ...standardIdentity, + package_name: "@scope/pkg", + subpath: "client/entry", + }; + const rootUrl = dependencyArtifactUpstreamUrl(identity); + assertEquals( + rootUrl, + "https://esm.sh/@scope/pkg@1.2.3/client/entry?external=react,react-dom&target=es2022", + ); + + const calls: string[] = []; + const graph = await buildDependencyArtifactGraph(identity, { + fetch: fixtureFetch({ [rootUrl]: response("export const value = 42;") }, calls), + }); + assertEquals(calls, [rootUrl]); + assertEquals(graph.assets.length, 1); + }); + + it("uses dedicated React profiles without duplicating React", () => { + assertEquals( + dependencyArtifactUpstreamUrl({ + ...standardIdentity, + package_name: "react", + subpath: "jsx-runtime", + profile: "react-v1", + }), + "https://esm.sh/react@1.2.3/jsx-runtime?target=es2022", + ); + assertEquals( + dependencyArtifactUpstreamUrl({ + ...standardIdentity, + package_name: "react-dom", + subpath: "client", + profile: "react-dom-v1", + }), + "https://esm.sh/react-dom@1.2.3/client?external=react&target=es2022", + ); + }); + + it("materializes a complete static and dynamic import closure", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const childUrl = "https://esm.sh/fixture-package@1.2.3/es2022/child.mjs"; + const dynamicUrl = "https://esm.sh/fixture-package@1.2.3/es2022/dynamic.mjs"; + const calls: string[] = []; + const graph = await buildDependencyArtifactGraph(standardIdentity, { + fetch: fixtureFetch({ + [rootUrl]: response( + 'import React from "react"; export { value } from "/fixture-package@1.2.3/es2022/child.mjs"; export const load = () => import("/fixture-package@1.2.3/es2022/dynamic.mjs");', + ), + [childUrl]: response("export const value = 42;"), + [dynamicUrl]: response("export default 'dynamic';"), + }, calls), + }); + + assertEquals(calls, [rootUrl, childUrl, dynamicUrl]); + assertEquals(graph.assets.length, 3); + assertEquals(graph.remainingExternalImportCount, 1); + const root = graph.assets.find((asset) => asset.contentHash === graph.rootContentHash); + assertEquals(root?.contentType, "text/javascript"); + const rootCode = new TextDecoder().decode(root?.bytes); + assertEquals(/https?:\/\/[^"'\s]+/.test(rootCode), false); + assertEquals(rootCode.includes('from "react"'), true); + assertEquals((rootCode.match(/\/_vf\/assets\/[0-9a-f]{64}\.js/g) ?? []).length, 2); + }); + + it("rejects foreign hosts before fetching them", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const calls: string[] = []; + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: response('export * from "https://example.com/child.js";'), + }, calls), + }); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "upstream_host_denied"); + assertEquals(calls, [rootUrl]); + assertEquals(events.map((event) => event.kind), ["result"]); + }); + + it("validates every redirect before fetching the next host", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const allowedRedirectUrl = "https://esm.sh/fixture-package@1.2.3/es2022/entry.mjs"; + const childUrl = "https://esm.sh/fixture-package@1.2.3/es2022/child.mjs"; + const calls: string[] = []; + const graph = await buildDependencyArtifactGraph(standardIdentity, { + fetch: fixtureFetch({ + [rootUrl]: new Response(null, { + status: 302, + headers: { location: allowedRedirectUrl }, + }), + [allowedRedirectUrl]: response('export * from "./child.mjs";'), + [childUrl]: response("export const value = 42;"), + }, calls), + }); + assertEquals(calls, [rootUrl, allowedRedirectUrl, childUrl]); + assertEquals(graph.assets.length, 2); + + const deniedCalls: string[] = []; + const { client } = recordingClient(); + const denied = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: new Response(null, { + status: 302, + headers: { location: "https://example.com/redirected.mjs" }, + }), + }, deniedCalls), + }); + assertEquals(failureCodeOf(denied), "upstream_host_denied"); + assertEquals(deniedCalls, [rootUrl]); + }); + + it("fails cyclic closures without uploading a partial graph", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const childUrl = "https://esm.sh/fixture-package@1.2.3/es2022/child.mjs"; + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: response('export * from "/fixture-package@1.2.3/es2022/child.mjs";'), + [childUrl]: response(`export * from ${JSON.stringify(rootUrl)};`), + }), + }); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "graph_cycle"); + assertEquals(events.map((event) => event.kind), ["result"]); + }); + + it("fails unsupported dynamic and auxiliary asset references", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + for ( + const [code, expectedFailureCode] of [ + ["export const load = (path) => import(path);", "non_literal_dynamic_import"], + [ + "export const value = 1;\n//# sourceMappingURL=module.js.map", + "unsupported_asset_reference", + ], + [ + 'export const worker = new URL("./worker.wasm", import.meta.url);', + "unsupported_asset_reference", + ], + ] as const + ) { + const calls: string[] = []; + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ [rootUrl]: response(code) }, calls), + }); + assertEquals(failureCodeOf(result), expectedFailureCode); + assertEquals(calls, [rootUrl]); + assertEquals(events.map((event) => event.kind), ["result"]); + } + }); + + it("rejects JavaScript imports of CSS assets before upload", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const cssUrl = "https://esm.sh/fixture-package@1.2.3/styles.css"; + const calls: string[] = []; + const { client, events } = recordingClient(); + + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: response('import "./styles.css"; export const value = 1;'), + [cssUrl]: response("body { color: red; }", "text/css"), + }, calls), + }); + + assertEquals(failureCodeOf(result), "unsupported_asset_reference"); + assertEquals(calls, [rootUrl, cssUrl]); + assertEquals(events.map((event) => event.kind), ["result"]); + }); + + it("enforces policy before starting any network work", async () => { + let fetchCalls = 0; + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild( + buildTaskInput({ + policy: { + decision: "too_young", + reason_code: "package_too_young", + retry_after: "2026-08-02T00:00:00.000Z", + }, + }), + client, + { + fetch: (async () => { + fetchCalls++; + return response("export {};"); + }) as typeof fetch, + }, + ); + + assertEquals(fetchCalls, 0); + assertEquals(failureCodeOf(result), "package_too_young"); + assertEquals(events.length, 1); + assertEquals(events[0]?.kind, "result"); + assertEquals( + (events[0]?.result as { retry_after?: string }).retry_after, + "2026-08-02T00:00:00.000Z", + ); + }); + + it("preserves policy failure identity when result reporting is unavailable", async () => { + let fetchCalls = 0; + const metrics: string[] = []; + const { client } = recordingClient(); + client.reportResult = () => Promise.reject(new Error("result API unavailable")); + + const result = await runDependencyArtifactBuild( + buildTaskInput({ + policy: { decision: "deny", reason_code: "package_denied" }, + }), + client, + { + fetch: (async () => { + fetchCalls++; + return response("export {};"); + }) as typeof fetch, + recordMetric: (metric) => metrics.push(`${metric.event}:${metric.failureCode ?? ""}`), + }, + ); + + assertEquals(fetchCalls, 0); + assertEquals(failureCodeOf(result), "package_denied"); + assertEquals(metrics, ["claim:", "failure:package_denied"]); + }); + + it("rejects HTML success responses and oversized assets", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + for ( + const [body, contentType, failureCode, maxAssetBytes] of [ + ["ESM build failed", "text/html", "upstream_html", 1024], + ["export const value = 'too large';", "text/javascript", "asset_size_limit", 8], + ] as const + ) { + const { client } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ [rootUrl]: response(body, contentType) }), + limits: { maxAssetBytes }, + }); + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), failureCode); + } + }); + + it("rejects unsuccessful and unsupported upstream responses", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + for ( + const [upstreamResponse, expectedFailureCode] of [ + [ + new Response("temporarily unavailable", { + status: 503, + headers: { "content-type": "text/plain" }, + }), + "upstream_http_error", + ], + [response("binary", "application/wasm"), "upstream_content_type"], + ] as const + ) { + const { client } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ [rootUrl]: upstreamResponse }), + }); + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), expectedFailureCode); + } + }); + + it("enforces total size, module count, and graph depth limits", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const childUrl = "https://esm.sh/fixture-package@1.2.3/es2022/child.mjs"; + const rootCode = 'export * from "/fixture-package@1.2.3/es2022/child.mjs";'; + const fixtures = () => ({ + [rootUrl]: response(rootCode), + [childUrl]: response("export const child = true;"), + }); + + for ( + const [limits, expectedFailureCode] of [ + [{ maxTotalBytes: encoder.encode(rootCode).byteLength }, "graph_total_size_limit"], + [{ maxModules: 1 }, "graph_module_limit"], + [{ maxDepth: 0 }, "graph_depth_limit"], + ] as const + ) { + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch(fixtures()), + limits, + }); + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), expectedFailureCode); + assertEquals(events.map((event) => event.kind), ["result"]); + } + }); + + it("bounds an upstream fetch that ignores AbortSignal", async () => { + const { client } = recordingClient(); + const upstreamSignals: AbortSignal[] = []; + const result = await settleBeforeWatchdog( + runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: ((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.signal) upstreamSignals.push(init.signal); + return new Promise(() => undefined); + }) as typeof fetch, + limits: { timeoutMs: 1 }, + }), + ); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "upstream_timeout"); + assertEquals(upstreamSignals[0]?.aborted, true); + }); + + it("bounds an upstream body read that never settles", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const { client } = recordingClient(); + const upstreamSignals: AbortSignal[] = []; + let bodyCancelCalls = 0; + const body = new ReadableStream({ + cancel: () => { + bodyCancelCalls++; + return new Promise(() => undefined); + }, + }); + + const result = await settleBeforeWatchdog( + runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.signal) upstreamSignals.push(init.signal); + assertEquals(String(input), rootUrl); + return new Response(body, { + headers: { "content-type": "text/javascript" }, + }); + }) as typeof fetch, + limits: { timeoutMs: 1 }, + }), + ); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "upstream_timeout"); + assertEquals(upstreamSignals[0]?.aborted, true); + assertEquals(bodyCancelCalls, 1); + }); + + it("rejects timeout values that JavaScript timers cannot represent", async () => { + for (const timeoutMs of [-1, 1.5, 2_147_483_648, Number.NaN]) { + let fetchCalls = 0; + const { client } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: (async () => { + fetchCalls++; + return response("export {};"); + }) as typeof fetch, + limits: { timeoutMs }, + }); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "invalid_limits"); + assertEquals(fetchCalls, 0); + } + }); + + it("rejects invalid build limits before starting network work", async () => { + const invalidLimits = [ + { maxAssetBytes: Number.NaN }, + { maxTotalBytes: Number.POSITIVE_INFINITY }, + { maxModules: 0 }, + { maxDepth: -1 }, + { maxDepth: 1.5 }, + ]; + + for (const limits of invalidLimits) { + let fetchCalls = 0; + const { client } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + limits, + fetch: (async () => { + fetchCalls++; + return response("export {};"); + }) as typeof fetch, + }); + + assertEquals(failureCodeOf(result), "invalid_limits"); + assertEquals(fetchCalls, 0); + } + }); + + it("uploads hash-verified assets before publishing one ready graph", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const childUrl = "https://esm.sh/fixture-package@1.2.3/es2022/child.mjs"; + const { client, events } = recordingClient(); + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: response('export * from "/fixture-package@1.2.3/es2022/child.mjs";'), + [childUrl]: response("export const value = 42;"), + }), + }); + + assertEquals(result.success, true); + assertEquals(events.map((event) => event.kind), ["upload", "upload", "result"]); + for (const event of events.filter((item) => item.kind === "upload")) { + assertEquals( + await computeHashBytes(new Uint8Array(event.bytes as Uint8Array)), + event.contentHash, + ); + } + const publication = events.at(-1)?.result as { + outcome: string; + graph: { root_content_hash: string; assets: unknown[] }; + }; + assertEquals(publication.outcome, "ready"); + if (!result.success) throw new Error("expected dependency artifact build success"); + assertEquals(publication.graph.root_content_hash, result.rootContentHash); + assertEquals(publication.graph.assets.length, 2); + }); + + it("fails when the API does not confirm ready publication", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const { client, events } = recordingClient(); + client.reportResult = async (input) => { + events.push({ kind: "result", ...input }); + return { accepted: true, state: "failed" }; + }; + + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ [rootUrl]: response("export const value = 42;") }), + }); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "result_state_mismatch"); + assertEquals(events.map((event) => event.kind), ["upload", "result", "result"]); + }); + + it("preserves the build failure when failure reporting is unavailable", async () => { + const rootUrl = dependencyArtifactUpstreamUrl(standardIdentity); + const metrics: string[] = []; + const { client } = recordingClient(); + client.reportResult = () => Promise.reject(new Error("result API unavailable")); + + const result = await runDependencyArtifactBuild(buildTaskInput(), client, { + fetch: fixtureFetch({ + [rootUrl]: response("", "text/html"), + }), + recordMetric: (metric) => metrics.push(`${metric.event}:${metric.failureCode ?? ""}`), + }); + + assertEquals(result.success, false); + assertEquals(failureCodeOf(result), "upstream_html"); + assertEquals(metrics, ["claim:", "failure:upstream_html"]); + }); + + it("uses graph map keys as canonical source identities", async () => { + const graph = await materializeDependencyArtifactGraph({ + modules: new Map([ + [ + "canonical-root", + { + id: "source-metadata-id", + code: "export const value = 42;", + contentType: "text/javascript" as const, + }, + ], + ]), + rootId: "canonical-root", + maxAssetBytes: 1_024, + resolveImport: () => ({ kind: "external" }), + }); + + assertEquals(graph.assets[0]?.sourceId, "canonical-root"); + assertEquals(graph.rootContentHash, graph.assets[0]?.contentHash); + }); + + it("validates the lease-bound task input without accepting arbitrary URLs", () => { + const parsed = parseDependencyArtifactBuildTaskInput(buildTaskInput()); + assertEquals(parsed.identity.exact_version, "1.2.3"); + + try { + parseDependencyArtifactBuildTaskInput({ + ...buildTaskInput(), + upstream_url: "https://example.com/package.js", + }); + throw new Error("expected validation failure"); + } catch (error) { + assertStringIncludes( + error instanceof Error ? error.message : String(error), + "Invalid dependency artifact build input", + ); + } + + for (const subpath of ["../escape", "feature/../../escape"]) { + try { + parseDependencyArtifactBuildTaskInput({ + ...buildTaskInput(), + identity: { ...standardIdentity, subpath }, + }); + throw new Error("expected validation failure"); + } catch (error) { + assertStringIncludes( + error instanceof Error ? error.message : String(error), + "Invalid dependency artifact build input", + ); + } + } + + try { + parseDependencyArtifactBuildTaskInput({ + ...buildTaskInput(), + attempt_count: Number.MAX_SAFE_INTEGER + 1, + }); + throw new Error("expected validation failure"); + } catch (error) { + assertStringIncludes( + error instanceof Error ? error.message : String(error), + "Invalid dependency artifact build input", + ); + } + }); +}); diff --git a/src/release-assets/dependency-artifact-builder.ts b/src/release-assets/dependency-artifact-builder.ts new file mode 100644 index 0000000000..2f70cbcbc3 --- /dev/null +++ b/src/release-assets/dependency-artifact-builder.ts @@ -0,0 +1,870 @@ +import { recordDependencyArtifactBuild } from "#veryfront/observability/metrics/index.ts"; +import { buildEsmShUrl } from "#veryfront/transforms/import-rewriter/url-builder.ts"; +import { looksLikeHtmlContent } from "#veryfront/transforms/esm/html-content.ts"; +import { computeHashBytes, serverLogger } from "#veryfront/utils"; +import { RELEASE_ASSET_MAX_SIZE_BYTES } from "./constants.ts"; +import type { + DependencyArtifactBuildResultBody, + DependencyArtifactBuildTaskInput, + DependencyArtifactContentType, + DependencyArtifactIdentity, + DependencyArtifactPolicyDecision, +} from "./dependency-artifact-contracts.ts"; +import { + type DependencyArtifactAsset, + DependencyArtifactGraphError, + type DependencyArtifactImportResolution, + type DependencyArtifactSourceModule, + materializeDependencyArtifactGraph, + readDependencyArtifactModuleSpecifiers, +} from "./dependency-artifact-graph.ts"; + +export type { + DependencyArtifactBuildResultBody, + DependencyArtifactBuildTaskInput, + DependencyArtifactContentType, + DependencyArtifactIdentity, + DependencyArtifactPolicyDecision, +} from "./dependency-artifact-contracts.ts"; +export { DEPENDENCY_ARTIFACT_BUILD_CAPABILITY } from "./dependency-artifact-contracts.ts"; + +const logger = serverLogger.component("dependency-artifact-build"); +const EXACT_SEMVER = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const PACKAGE_NAME = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const SUBPATH = /^(?:[A-Za-z0-9._~+-]+)(?:\/[A-Za-z0-9._~+-]+)*$/; +const FAILURE_CODE = /^[a-z][a-z0-9_-]{0,63}$/; +const MAX_UPSTREAM_REDIRECTS = 5; +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export interface DependencyArtifactBuildClient { + uploadAsset(input: { + artifactId: string; + attemptCount: number; + contentHash: string; + contentType: DependencyArtifactContentType; + bytes: Uint8Array; + }): Promise<{ stored: true; existed: boolean }>; + reportResult(input: { + artifactId: string; + attemptCount: number; + result: DependencyArtifactBuildResultBody; + }): Promise<{ accepted: true; state: "ready" | "failed" }>; +} + +export interface DependencyArtifactBuildLimits { + maxAssetBytes: number; + maxTotalBytes: number; + maxModules: number; + maxDepth: number; + timeoutMs: number; +} + +export interface DependencyArtifactBuildMetric { + event: "claim" | "success" | "failure"; + durationMs?: number; + totalBytes?: number; + assetCount?: number; + remainingExternalImportCount?: number; + failureCode?: string; +} + +export interface DependencyArtifactBuilderDeps { + fetch?: typeof fetch; + limits?: Partial; + now?: () => number; + recordMetric?: (metric: DependencyArtifactBuildMetric) => void; +} + +const DEFAULT_LIMITS: DependencyArtifactBuildLimits = { + maxAssetBytes: RELEASE_ASSET_MAX_SIZE_BYTES, + maxTotalBytes: 64 * 1024 * 1024, + maxModules: 1024, + maxDepth: 64, + timeoutMs: 30_000, +}; + +export class DependencyArtifactBuildError extends Error { + constructor( + readonly failureCode: string, + message: string, + ) { + super(message); + this.name = "DependencyArtifactBuildError"; + } +} + +export function parseDependencyArtifactBuildTaskInput( + value: unknown, +): DependencyArtifactBuildTaskInput { + if ( + !isRecord(value) || !hasOnlyKeys(value, ["artifact_id", "attempt_count", "identity", "policy"]) + ) { + throw new DependencyArtifactBuildError( + "invalid_task_input", + "Invalid dependency artifact build input", + ); + } + + const identity = parseIdentity(value.identity); + const policy = parsePolicy(value.policy); + if ( + typeof value.artifact_id !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value.artifact_id, + ) || + !Number.isSafeInteger(value.attempt_count) || + (value.attempt_count as number) <= 0 + ) { + throw new DependencyArtifactBuildError( + "invalid_task_input", + "Invalid dependency artifact build input", + ); + } + + return { + artifact_id: value.artifact_id, + attempt_count: value.attempt_count as number, + identity, + policy, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const allowedKeys = new Set(allowed); + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +function invalidTaskInput(): never { + throw new DependencyArtifactBuildError( + "invalid_task_input", + "Invalid dependency artifact build input", + ); +} + +function parseIdentity(value: unknown): DependencyArtifactIdentity { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + "origin_key", + "package_name", + "exact_version", + "subpath", + "target", + "profile", + ]) || + value.origin_key !== "npm:public" || + typeof value.package_name !== "string" || + value.package_name.length > 214 || + !PACKAGE_NAME.test(value.package_name) || + typeof value.exact_version !== "string" || + value.exact_version.length > 128 || + !EXACT_SEMVER.test(value.exact_version) || + typeof value.subpath !== "string" || + value.subpath.length > 512 || + (value.subpath !== "" && !SUBPATH.test(value.subpath)) || + value.subpath.split("/").some((segment) => segment === "." || segment === "..") || + value.target !== "es2022" || + (value.profile !== "standard-v1" && + value.profile !== "react-v1" && + value.profile !== "react-dom-v1") + ) { + return invalidTaskInput(); + } + + const profileMatches = value.package_name === "react" + ? value.profile === "react-v1" + : value.package_name === "react-dom" + ? value.profile === "react-dom-v1" + : value.profile === "standard-v1"; + if (!profileMatches) return invalidTaskInput(); + + return value as unknown as DependencyArtifactIdentity; +} + +function parsePolicy(value: unknown): DependencyArtifactPolicyDecision { + if (!isRecord(value) || typeof value.decision !== "string") return invalidTaskInput(); + if (value.decision === "allow") { + if (!hasOnlyKeys(value, ["decision"])) return invalidTaskInput(); + return { decision: "allow" }; + } + + const expectedKeys = value.decision === "too_young" + ? ["decision", "reason_code", "retry_after"] + : ["decision", "reason_code"]; + if ( + (value.decision !== "deny" && value.decision !== "too_young") || + !hasOnlyKeys(value, expectedKeys) || + typeof value.reason_code !== "string" || + !FAILURE_CODE.test(value.reason_code) + ) { + return invalidTaskInput(); + } + + if (value.decision === "deny") { + return { decision: "deny", reason_code: value.reason_code }; + } + if ( + typeof value.retry_after !== "string" || + Number.isNaN(Date.parse(value.retry_after)) || + !/[zZ]|[+-]\d{2}:\d{2}$/.test(value.retry_after) + ) { + return invalidTaskInput(); + } + return { + decision: "too_young", + reason_code: value.reason_code, + retry_after: value.retry_after, + }; +} + +function profileExternals(identity: DependencyArtifactIdentity): string[] { + if (identity.profile === "react-v1") return []; + if (identity.profile === "react-dom-v1") return ["react"]; + return ["react", "react-dom"]; +} + +export function dependencyArtifactUpstreamUrl(identity: DependencyArtifactIdentity): string { + const subpath = identity.subpath ? `/${identity.subpath}` : undefined; + return buildEsmShUrl(identity.package_name, identity.exact_version, subpath, { + external: profileExternals(identity), + target: identity.target, + }); +} + +function isExternalSpecifier(specifier: string, externals: readonly string[]): boolean { + return externals.some((external) => + specifier === external || specifier.startsWith(`${external}/`) + ); +} + +function isBareSpecifier(specifier: string): boolean { + return !specifier.startsWith("/") && + !specifier.startsWith("./") && + !specifier.startsWith("../") && + !specifier.includes(":"); +} + +function resolveUpstreamImport( + specifier: string, + parentUrl: string, + externals: readonly string[], +): DependencyArtifactImportResolution { + if (isBareSpecifier(specifier)) { + return isExternalSpecifier(specifier, externals) + ? { kind: "external" } + : { kind: "invalid", failureCode: "undeclared_external" }; + } + + let url: URL; + try { + url = new URL(specifier, parentUrl); + } catch { + return { kind: "invalid", failureCode: "unresolved_import" }; + } + if ( + url.protocol !== "https:" || + url.hostname !== "esm.sh" || + url.port !== "" || + url.username !== "" || + url.password !== "" + ) { + return { kind: "invalid", failureCode: "upstream_host_denied" }; + } + url.hash = ""; + return { kind: "module", moduleId: url.toString() }; +} + +function resolveAllowedUpstreamUrl(value: string, baseUrl: string): string { + let url: URL; + try { + url = new URL(value, baseUrl); + } catch { + throw new DependencyArtifactBuildError( + "upstream_redirect_invalid", + "Dependency artifact upstream returned an invalid redirect", + ); + } + if ( + url.protocol !== "https:" || + url.hostname !== "esm.sh" || + url.port !== "" || + url.username !== "" || + url.password !== "" + ) { + throw new DependencyArtifactBuildError( + "upstream_host_denied", + "Dependency artifact upstream redirected to a denied host", + ); + } + url.hash = ""; + return url.toString(); +} + +function normalizeContentType(value: string | null): DependencyArtifactContentType | null { + const contentType = value?.split(";", 1)[0]?.trim().toLowerCase(); + if ( + contentType === "text/javascript" || + contentType === "application/javascript" || + contentType === "text/ecmascript" || + contentType === "application/ecmascript" + ) { + return "text/javascript"; + } + if (contentType === "text/css") return "text/css"; + return null; +} + +function sanitizedFailureMessage(error: unknown): string { + if (error instanceof DependencyArtifactBuildError) return error.message; + if (error instanceof DependencyArtifactGraphError) return error.message; + return "Dependency artifact build failed"; +} + +function defaultRecordMetric(metric: DependencyArtifactBuildMetric): void { + logger.info("Dependency artifact build metric", { + event: metric.event, + duration_ms: metric.durationMs, + total_bytes: metric.totalBytes, + asset_count: metric.assetCount, + remaining_external_import_count: metric.remainingExternalImportCount, + failure_code: metric.failureCode, + }); + recordDependencyArtifactBuild(metric); +} + +function mergeLimits( + overrides?: Partial, +): DependencyArtifactBuildLimits { + const limits = { ...DEFAULT_LIMITS, ...overrides }; + if ( + !Number.isSafeInteger(limits.maxAssetBytes) || + limits.maxAssetBytes <= 0 || + !Number.isSafeInteger(limits.maxTotalBytes) || + limits.maxTotalBytes <= 0 || + !Number.isSafeInteger(limits.maxModules) || + limits.maxModules <= 0 || + !Number.isSafeInteger(limits.maxDepth) || + limits.maxDepth < 0 || + !Number.isSafeInteger(limits.timeoutMs) || + limits.timeoutMs < 0 || + limits.timeoutMs > MAX_TIMER_DELAY_MS + ) { + throw new DependencyArtifactBuildError( + "invalid_limits", + "Dependency artifact build limits are invalid", + ); + } + return limits; +} + +interface UpstreamDeadline { + readonly signal: AbortSignal; + race(operation: () => Promise, cleanup?: () => void): Promise; + dispose(): void; +} + +function upstreamTimeoutError(): DependencyArtifactBuildError { + return new DependencyArtifactBuildError( + "upstream_timeout", + "Dependency artifact upstream request timed out", + ); +} + +function createUpstreamDeadline(timeoutMs: number): UpstreamDeadline { + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 0 || + timeoutMs > MAX_TIMER_DELAY_MS + ) { + throw new DependencyArtifactBuildError( + "invalid_limits", + `Dependency artifact timeout must be an integer between 0 and ${MAX_TIMER_DELAY_MS}`, + ); + } + + const controller = new AbortController(); + const expiresAt = performance.now() + timeoutMs; + const timeoutError = upstreamTimeoutError(); + let expired = false; + let disposed = false; + let activeOperation: { token: object; cleanup?: () => void } | undefined; + let rejectDeadline!: (error: DependencyArtifactBuildError) => void; + const deadline = new Promise((_resolve, reject) => { + rejectDeadline = reject; + }); + + const expire = (): void => { + if (expired || disposed) return; + expired = true; + rejectDeadline(timeoutError); + controller.abort(timeoutError); + try { + activeOperation?.cleanup?.(); + } catch { + // Cancellation is best-effort cleanup; the deadline rejection is authoritative. + } + }; + const timeout = setTimeout(expire, timeoutMs); + + return { + signal: controller.signal, + async race(operation: () => Promise, cleanup?: () => void): Promise { + if (expired || performance.now() >= expiresAt) { + expire(); + return await deadline; + } + + const token = {}; + activeOperation = { token, cleanup }; + try { + return await Promise.race([operation(), deadline]); + } finally { + if (activeOperation?.token === token) activeOperation = undefined; + } + }, + dispose(): void { + if (disposed) return; + disposed = true; + activeOperation = undefined; + clearTimeout(timeout); + }, + }; +} + +function cancelResponseBody(response: Response, reason?: unknown): void { + void response.body?.cancel(reason).catch(() => undefined); +} + +async function fetchSourceModules( + identity: DependencyArtifactIdentity, + fetcher: typeof fetch, + limits: DependencyArtifactBuildLimits, +): Promise<{ + modules: Map; + rootId: string; +}> { + const rootId = dependencyArtifactUpstreamUrl(identity); + const externals = profileExternals(identity); + const modules = new Map(); + let totalBytes = 0; + const deadline = createUpstreamDeadline(limits.timeoutMs); + + async function fetchAllowedModule(moduleId: string): Promise<{ + response: Response; + finalUrl: string; + }> { + let currentUrl = resolveAllowedUpstreamUrl(moduleId, moduleId); + for (let redirectCount = 0; redirectCount <= MAX_UPSTREAM_REDIRECTS; redirectCount++) { + let response: Response; + try { + response = await deadline.race(() => + fetcher(currentUrl, { + headers: { + accept: "text/javascript, application/javascript, text/css;q=0.9", + "user-agent": "Mozilla/5.0 Veryfront/1.0", + }, + redirect: "manual", + signal: deadline.signal, + }) + ); + } catch (error) { + if ( + deadline.signal.aborted || + (error instanceof Error && error.name === "AbortError") + ) { + throw upstreamTimeoutError(); + } + throw new DependencyArtifactBuildError( + "upstream_fetch_failed", + "Dependency artifact upstream request failed", + ); + } + + if (response.status < 300 || response.status >= 400) { + return { response, finalUrl: currentUrl }; + } + + const location = response.headers.get("location"); + cancelResponseBody(response); + if (!location) { + throw new DependencyArtifactBuildError( + "upstream_redirect_invalid", + "Dependency artifact upstream returned an invalid redirect", + ); + } + if (redirectCount === MAX_UPSTREAM_REDIRECTS) { + throw new DependencyArtifactBuildError( + "upstream_redirect_limit", + "Dependency artifact upstream exceeded the redirect limit", + ); + } + currentUrl = resolveAllowedUpstreamUrl(location, currentUrl); + } + + throw new DependencyArtifactBuildError( + "upstream_redirect_limit", + "Dependency artifact upstream exceeded the redirect limit", + ); + } + + async function visit(moduleId: string, depth: number): Promise { + if (modules.has(moduleId)) return; + if (depth > limits.maxDepth) { + throw new DependencyArtifactBuildError( + "graph_depth_limit", + "Dependency artifact graph exceeds the depth limit", + ); + } + if (modules.size >= limits.maxModules) { + throw new DependencyArtifactBuildError( + "graph_module_limit", + "Dependency artifact graph exceeds the module limit", + ); + } + + const { response, finalUrl } = await fetchAllowedModule(moduleId); + + if (!response.ok) { + cancelResponseBody(response); + throw new DependencyArtifactBuildError( + "upstream_http_error", + "Dependency artifact upstream returned an unsuccessful response", + ); + } + const rawContentType = response.headers.get("content-type"); + if (rawContentType?.toLowerCase().includes("text/html")) { + cancelResponseBody(response); + throw new DependencyArtifactBuildError( + "upstream_html", + "Dependency artifact upstream returned HTML", + ); + } + const contentType = normalizeContentType(rawContentType); + if (!contentType) { + cancelResponseBody(response); + throw new DependencyArtifactBuildError( + "upstream_content_type", + "Dependency artifact upstream returned an unsupported content type", + ); + } + + let bytes: Uint8Array; + try { + bytes = await readBoundedResponseBytes(response, totalBytes, limits, deadline); + } catch (error) { + if ( + error instanceof DependencyArtifactBuildError || + error instanceof DependencyArtifactGraphError + ) { + throw error; + } + if ( + deadline.signal.aborted || + (error instanceof Error && error.name === "AbortError") + ) { + throw upstreamTimeoutError(); + } + throw new DependencyArtifactBuildError( + "upstream_fetch_failed", + "Dependency artifact upstream request failed", + ); + } + totalBytes += bytes.byteLength; + + const code = new TextDecoder().decode(bytes); + if (looksLikeHtmlContent(code)) { + throw new DependencyArtifactBuildError( + "upstream_html", + "Dependency artifact upstream returned HTML", + ); + } + + const module: DependencyArtifactSourceModule = { + id: moduleId, + code, + contentType, + resolutionBaseId: finalUrl, + }; + modules.set(moduleId, module); + for (const specifier of await readDependencyArtifactModuleSpecifiers(module)) { + const resolution = resolveUpstreamImport(specifier, finalUrl, externals); + if (resolution.kind === "external") continue; + if (resolution.kind === "invalid") { + throw new DependencyArtifactBuildError( + resolution.failureCode, + "Dependency artifact contains an import outside its allowed closure", + ); + } + await visit(resolution.moduleId, depth + 1); + } + } + + try { + await visit(rootId, 0); + return { modules, rootId }; + } finally { + deadline.dispose(); + } +} + +async function readBoundedResponseBytes( + response: Response, + currentTotalBytes: number, + limits: DependencyArtifactBuildLimits, + deadline: UpstreamDeadline, +): Promise> { + const declaredLengthHeader = response.headers.get("content-length"); + if (declaredLengthHeader !== null) { + const declaredLength = Number(declaredLengthHeader); + if (Number.isFinite(declaredLength) && declaredLength >= 0) { + if (declaredLength > limits.maxAssetBytes) { + cancelResponseBody(response); + throw new DependencyArtifactBuildError( + "asset_size_limit", + "Dependency artifact asset exceeds the size limit", + ); + } + if (currentTotalBytes + declaredLength > limits.maxTotalBytes) { + cancelResponseBody(response); + throw new DependencyArtifactBuildError( + "graph_total_size_limit", + "Dependency artifact graph exceeds the total size limit", + ); + } + } + } + + if (!response.body) return new Uint8Array(); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await deadline.race( + () => reader.read(), + () => void reader.cancel(upstreamTimeoutError()).catch(() => undefined), + ); + if (done) break; + size += value.byteLength; + if (size > limits.maxAssetBytes) { + void reader.cancel().catch(() => undefined); + throw new DependencyArtifactBuildError( + "asset_size_limit", + "Dependency artifact asset exceeds the size limit", + ); + } + if (currentTotalBytes + size > limits.maxTotalBytes) { + void reader.cancel().catch(() => undefined); + throw new DependencyArtifactBuildError( + "graph_total_size_limit", + "Dependency artifact graph exceeds the total size limit", + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function buildDependencyArtifactGraph( + identity: DependencyArtifactIdentity, + deps: DependencyArtifactBuilderDeps = {}, +): Promise<{ + assets: DependencyArtifactAsset[]; + rootContentHash: string; + remainingExternalImportCount: number; +}> { + const limits = mergeLimits(deps.limits); + const { modules, rootId } = await fetchSourceModules( + identity, + deps.fetch ?? fetch, + limits, + ); + const externals = profileExternals(identity); + return await materializeDependencyArtifactGraph({ + modules, + rootId, + maxAssetBytes: limits.maxAssetBytes, + resolveImport: (specifier, parent) => + resolveUpstreamImport(specifier, parent.resolutionBaseId ?? parent.id, externals), + }); +} + +function failureCode(error: unknown): string { + if (error instanceof DependencyArtifactBuildError) return error.failureCode; + if (error instanceof DependencyArtifactGraphError) return error.failureCode; + return "build_failed"; +} + +function uniqueAssets(assets: readonly DependencyArtifactAsset[]): DependencyArtifactAsset[] { + return [...new Map(assets.map((asset) => [asset.contentHash, asset])).values()]; +} + +async function reportFailedResult( + client: DependencyArtifactBuildClient, + input: DependencyArtifactBuildTaskInput, + result: Extract, +): Promise { + try { + await client.reportResult({ + artifactId: input.artifact_id, + attemptCount: input.attempt_count, + result, + }); + } catch (reportingError) { + logger.warn("Dependency artifact failure result could not be reported", { + failure_code: result.failure_code, + error: reportingError, + }); + } +} + +export async function runDependencyArtifactBuild( + input: DependencyArtifactBuildTaskInput, + client: DependencyArtifactBuildClient, + deps: DependencyArtifactBuilderDeps = {}, +): Promise< + | { + success: true; + state: "ready"; + rootContentHash: string; + assetCount: number; + totalBytes: number; + remainingExternalImportCount: number; + durationMs: number; + } + | { + success: false; + state: "failed"; + failureCode: string; + durationMs: number; + } +> { + const now = deps.now ?? Date.now; + const recordMetric = deps.recordMetric ?? defaultRecordMetric; + const startedAt = now(); + recordMetric({ event: "claim" }); + + try { + if (input.policy.decision !== "allow") { + const failedResult: DependencyArtifactBuildResultBody = { + outcome: "failed", + failure_code: input.policy.reason_code, + failure_message: input.policy.decision === "too_young" + ? "Dependency artifact package age policy denied the build" + : "Dependency artifact policy denied the build", + ...(input.policy.decision === "too_young" ? { retry_after: input.policy.retry_after } : {}), + }; + await reportFailedResult(client, input, failedResult); + const durationMs = Math.max(0, now() - startedAt); + recordMetric({ + event: "failure", + durationMs, + failureCode: input.policy.reason_code, + assetCount: 0, + totalBytes: 0, + remainingExternalImportCount: 0, + }); + return { + success: false, + state: "failed", + failureCode: input.policy.reason_code, + durationMs, + }; + } + + const built = await buildDependencyArtifactGraph(input.identity, deps); + const assets = uniqueAssets(built.assets); + for (const asset of assets) { + if (await computeHashBytes(asset.bytes) !== asset.contentHash) { + throw new DependencyArtifactBuildError( + "hash_mismatch", + "Dependency artifact hash verification failed", + ); + } + await client.uploadAsset({ + artifactId: input.artifact_id, + attemptCount: input.attempt_count, + contentHash: asset.contentHash, + contentType: asset.contentType, + bytes: asset.bytes, + }); + } + + const publication: DependencyArtifactBuildResultBody = { + outcome: "ready", + graph: { + graph_schema_version: 1, + root_content_hash: built.rootContentHash, + assets: assets.map((asset) => ({ + content_hash: asset.contentHash, + content_type: asset.contentType, + size: asset.size, + })), + }, + }; + const published = await client.reportResult({ + artifactId: input.artifact_id, + attemptCount: input.attempt_count, + result: publication, + }); + if (published.state !== "ready") { + throw new DependencyArtifactBuildError( + "result_state_mismatch", + "Dependency artifact result was not published ready", + ); + } + + const durationMs = Math.max(0, now() - startedAt); + const totalBytes = assets.reduce((sum, asset) => sum + asset.size, 0); + recordMetric({ + event: "success", + durationMs, + totalBytes, + assetCount: assets.length, + remainingExternalImportCount: built.remainingExternalImportCount, + }); + return { + success: true, + state: "ready", + rootContentHash: built.rootContentHash, + assetCount: assets.length, + totalBytes, + remainingExternalImportCount: built.remainingExternalImportCount, + durationMs, + }; + } catch (error) { + const code = failureCode(error); + const result: DependencyArtifactBuildResultBody = { + outcome: "failed", + failure_code: code, + failure_message: sanitizedFailureMessage(error), + }; + await reportFailedResult(client, input, result); + const durationMs = Math.max(0, now() - startedAt); + recordMetric({ + event: "failure", + durationMs, + failureCode: code, + totalBytes: 0, + assetCount: 0, + remainingExternalImportCount: 0, + }); + return { success: false, state: "failed", failureCode: code, durationMs }; + } +} diff --git a/src/release-assets/dependency-artifact-contracts.ts b/src/release-assets/dependency-artifact-contracts.ts new file mode 100644 index 0000000000..5768d3ab5f --- /dev/null +++ b/src/release-assets/dependency-artifact-contracts.ts @@ -0,0 +1,51 @@ +export type DependencyArtifactContentType = "text/javascript" | "text/css"; + +export const DEPENDENCY_ARTIFACT_BUILD_CAPABILITY = "dependency-artifact-build-v1" as const; + +export interface DependencyArtifactIdentity { + origin_key: "npm:public"; + package_name: string; + exact_version: string; + subpath: string; + target: "es2022"; + profile: "standard-v1" | "react-v1" | "react-dom-v1"; +} + +export type DependencyArtifactPolicyDecision = + | { decision: "allow" } + | { decision: "deny"; reason_code: string } + | { + decision: "too_young"; + reason_code: string; + retry_after: string; + }; + +export interface DependencyArtifactBuildTaskInput { + artifact_id: string; + attempt_count: number; + identity: DependencyArtifactIdentity; + policy: DependencyArtifactPolicyDecision; +} + +export type DependencyArtifactBuildResultBody = + | { + outcome: "ready"; + // The authenticated attempt lease owns the canonical identity, key/profile, + // policy decision, and timestamps. The API combines that durable metadata + // with this strictly verified publication payload. + graph: { + graph_schema_version: 1; + root_content_hash: string; + assets: Array<{ + content_hash: string; + content_type: DependencyArtifactContentType; + size: number; + }>; + }; + } + | { + outcome: "failed"; + failure_code: string; + failure_message?: string; + retry_after?: string; + }; diff --git a/src/release-assets/dependency-artifact-graph.ts b/src/release-assets/dependency-artifact-graph.ts new file mode 100644 index 0000000000..a1a0a287a5 --- /dev/null +++ b/src/release-assets/dependency-artifact-graph.ts @@ -0,0 +1,349 @@ +import { ensureDefaultBundlerContracts } from "#veryfront/extensions/bundler/defaults.ts"; +import { parseImports, replaceSpecifiers } from "#veryfront/transforms/esm/lexer.ts"; +import { computeHashBytes } from "#veryfront/utils"; +import { releaseAssetUrl } from "./constants.ts"; +import type { DependencyArtifactContentType } from "./dependency-artifact-contracts.ts"; + +export interface DependencyArtifactSourceModule { + id: string; + code: string; + contentType: DependencyArtifactContentType; + resolutionBaseId?: string; +} + +export interface DependencyArtifactAsset { + sourceId: string; + contentHash: string; + contentType: DependencyArtifactContentType; + size: number; + bytes: Uint8Array; +} + +export type DependencyArtifactImportResolution = + | { kind: "module"; moduleId: string } + | { kind: "external" } + | { kind: "invalid"; failureCode: string }; + +export class DependencyArtifactGraphError extends Error { + constructor( + readonly failureCode: string, + message: string, + ) { + super(message); + this.name = "DependencyArtifactGraphError"; + } +} + +const CSS_REFERENCE_PATTERN = + /@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?|url\(\s*["']?([^"')\s]+)["']?\s*\)/gi; +const SOURCE_MAP_REFERENCE_PATTERN = + /(?:\/\/[#@]\s*sourceMappingURL=([^\s]+)|\/\*[#@]\s*sourceMappingURL=([^*\s]+)\s*\*\/)/gi; +const MODULE_RELATIVE_ASSET_PATTERN = + /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/i; + +function assertNoUnsupportedReferences(module: DependencyArtifactSourceModule): void { + for (const match of module.code.matchAll(SOURCE_MAP_REFERENCE_PATTERN)) { + const reference = match[1] ?? match[2]; + if (reference && !reference.startsWith("data:")) { + throw new DependencyArtifactGraphError( + "unsupported_asset_reference", + "Dependency artifact contains an unsupported source map reference", + ); + } + } + + if ( + module.contentType === "text/javascript" && + MODULE_RELATIVE_ASSET_PATTERN.test(module.code) + ) { + throw new DependencyArtifactGraphError( + "unsupported_asset_reference", + "Dependency artifact contains an unsupported module-relative asset reference", + ); + } +} + +function cssSpecifiers(code: string): string[] { + return [...code.matchAll(CSS_REFERENCE_PATTERN)] + .map((match) => match[1] ?? match[2]) + .filter((value): value is string => + typeof value === "string" && + value.length > 0 && + !value.startsWith("data:") && + !value.startsWith("#") + ); +} + +export async function readDependencyArtifactModuleSpecifiers( + module: DependencyArtifactSourceModule, + options: { rejectUnsupportedReferences?: boolean } = { + rejectUnsupportedReferences: true, + }, +): Promise { + if (options.rejectUnsupportedReferences !== false) { + assertNoUnsupportedReferences(module); + } + if (module.contentType === "text/css") return cssSpecifiers(module.code); + await ensureDefaultBundlerContracts(); + const imports = await parseImports(module.code); + if ( + options.rejectUnsupportedReferences !== false && + imports.some((specifier) => specifier.d > -1 && typeof specifier.n !== "string") + ) { + throw new DependencyArtifactGraphError( + "non_literal_dynamic_import", + "Dependency artifact contains a non-literal dynamic import", + ); + } + return imports + .map((specifier) => specifier.n) + .filter((specifier): specifier is string => typeof specifier === "string"); +} + +async function rewriteModuleSpecifiers( + module: DependencyArtifactSourceModule, + replacements: ReadonlyMap, +): Promise { + if (module.contentType === "text/javascript") { + return await replaceSpecifiers(module.code, (specifier) => replacements.get(specifier)); + } + + return module.code.replace( + CSS_REFERENCE_PATTERN, + (match, importSpecifier: string | undefined, urlSpecifier: string | undefined) => { + const specifier = importSpecifier ?? urlSpecifier; + if (!specifier) return match; + const replacement = replacements.get(specifier); + return replacement ? match.replace(specifier, replacement) : match; + }, + ); +} + +function assetExtension(contentType: DependencyArtifactContentType): "js" | "css" { + return contentType === "text/css" ? "css" : "js"; +} + +interface MaterializeModuleGraphInput { + modules: ReadonlyMap; + entryIds: readonly string[]; + maxAssetBytes: number; + resolveImport( + specifier: string, + parent: DependencyArtifactSourceModule, + ): DependencyArtifactImportResolution; + cycleFallbackUrl?(module: DependencyArtifactSourceModule): string | null; + onCycle?(cycleIds: readonly string[]): void; + assetSizeErrorMessage?(module: DependencyArtifactSourceModule): string; + validateCompleteGraph: boolean; +} + +interface MaterializedModuleGraph { + assets: DependencyArtifactAsset[]; + remainingExternalImportCount: number; + skippedCycleIds: Set; +} + +async function materializeModuleGraph( + input: MaterializeModuleGraphInput, +): Promise { + const finalized = new Map(); + const skippedCycleIds = new Set(); + const visiting: string[] = []; + let remainingExternalImportCount = 0; + const encoder = new TextEncoder(); + + async function finalize(moduleId: string): Promise { + const existing = finalized.get(moduleId); + if (existing) return existing; + if (skippedCycleIds.has(moduleId)) return null; + + const cycleIndex = visiting.indexOf(moduleId); + if (cycleIndex !== -1) { + if (!input.cycleFallbackUrl) { + throw new DependencyArtifactGraphError( + "graph_cycle", + "Dependency artifact graph contains an unsupported import cycle", + ); + } + const cycleIds = [...visiting.slice(cycleIndex), moduleId]; + for (const cycleId of cycleIds) skippedCycleIds.add(cycleId); + input.onCycle?.(cycleIds); + return null; + } + + const module = input.modules.get(moduleId); + if (!module) { + throw new DependencyArtifactGraphError( + "graph_incomplete", + "Dependency artifact graph references a missing module", + ); + } + + visiting.push(moduleId); + try { + const replacements = new Map(); + for ( + const specifier of await readDependencyArtifactModuleSpecifiers(module, { + rejectUnsupportedReferences: input.validateCompleteGraph, + }) + ) { + const resolution = input.resolveImport(specifier, module); + if (resolution.kind === "external") { + remainingExternalImportCount++; + continue; + } + if (resolution.kind === "invalid") { + throw new DependencyArtifactGraphError( + resolution.failureCode, + "Dependency artifact graph contains an unresolved import", + ); + } + + const child = await finalize(resolution.moduleId); + if (!child) { + const childModule = input.modules.get(resolution.moduleId); + const fallback = childModule && input.cycleFallbackUrl?.(childModule); + if (!fallback) { + throw new DependencyArtifactGraphError( + "graph_cycle", + "Dependency artifact graph contains an unrepresentable import cycle", + ); + } + replacements.set(specifier, fallback); + continue; + } + if ( + module.contentType === "text/javascript" && child.contentType !== "text/javascript" + ) { + throw new DependencyArtifactGraphError( + "unsupported_asset_reference", + "JavaScript dependency artifacts cannot import non-JavaScript assets", + ); + } + if (module.contentType === "text/css" && child.contentType !== "text/css") { + throw new DependencyArtifactGraphError( + "unsupported_asset_reference", + "CSS dependency artifacts cannot reference non-CSS assets", + ); + } + replacements.set( + specifier, + releaseAssetUrl(child.contentHash, assetExtension(child.contentType)), + ); + } + + if (skippedCycleIds.has(moduleId)) return null; + + const code = await rewriteModuleSpecifiers(module, replacements); + const bytes = encoder.encode(code) as Uint8Array; + if (bytes.byteLength > input.maxAssetBytes) { + throw new DependencyArtifactGraphError( + "asset_size_limit", + input.assetSizeErrorMessage?.(module) ?? + "Dependency artifact asset exceeds the size limit", + ); + } + + const asset: DependencyArtifactAsset = { + sourceId: moduleId, + contentHash: await computeHashBytes(bytes), + contentType: module.contentType, + size: bytes.byteLength, + bytes, + }; + finalized.set(moduleId, asset); + return asset; + } finally { + visiting.pop(); + } + } + + for (const entryId of input.entryIds) await finalize(entryId); + + const assets = [...finalized.values()]; + if (input.validateCompleteGraph) { + const contentHashes = new Set(assets.map((asset) => asset.contentHash)); + for (const asset of assets) { + for ( + const specifier of await readDependencyArtifactModuleSpecifiers( + { + id: asset.sourceId, + code: new TextDecoder().decode(asset.bytes), + contentType: asset.contentType, + }, + { rejectUnsupportedReferences: true }, + ) + ) { + const resolution = input.resolveImport(specifier, input.modules.get(asset.sourceId)!); + if (resolution.kind === "external") continue; + const match = /^\/_vf\/assets\/([0-9a-f]{64})\.(?:js|css)$/.exec(specifier); + if (!match?.[1] || !contentHashes.has(match[1])) { + throw new DependencyArtifactGraphError( + "graph_incomplete", + "Dependency artifact graph contains a non-materialized import", + ); + } + } + } + } + + return { + assets, + remainingExternalImportCount, + skippedCycleIds, + }; +} + +export async function materializeDependencyArtifactGraph(input: { + modules: ReadonlyMap; + rootId: string; + maxAssetBytes: number; + resolveImport( + specifier: string, + parent: DependencyArtifactSourceModule, + ): DependencyArtifactImportResolution; +}): Promise<{ + assets: DependencyArtifactAsset[]; + rootContentHash: string; + remainingExternalImportCount: number; +}> { + const result = await materializeModuleGraph({ + ...input, + entryIds: [input.rootId], + validateCompleteGraph: true, + }); + const root = result.assets.find((asset) => asset.sourceId === input.rootId); + if (!root || root.contentType !== "text/javascript") { + throw new DependencyArtifactGraphError( + "invalid_root_content_type", + "Dependency artifact root must be JavaScript", + ); + } + return { + assets: result.assets, + rootContentHash: root.contentHash, + remainingExternalImportCount: result.remainingExternalImportCount, + }; +} + +export async function materializeReleaseDependencyGraph(input: { + modules: ReadonlyMap; + maxAssetBytes: number; + resolveImport( + specifier: string, + parent: DependencyArtifactSourceModule, + ): DependencyArtifactImportResolution; + cycleFallbackUrl(module: DependencyArtifactSourceModule): string | null; + onCycle(cycleIds: readonly string[]): void; + assetSizeErrorMessage?(module: DependencyArtifactSourceModule): string; +}): Promise<{ + assets: DependencyArtifactAsset[]; + skippedCycleIds: Set; +}> { + const result = await materializeModuleGraph({ + ...input, + entryIds: [...input.modules.keys()], + validateCompleteGraph: false, + }); + return { assets: result.assets, skippedCycleIds: result.skippedCycleIds }; +} diff --git a/src/server/handlers/monitoring/health.handler.ts b/src/server/handlers/monitoring/health.handler.ts index 22f86f8d7f..d0e0e2ac36 100644 --- a/src/server/handlers/monitoring/health.handler.ts +++ b/src/server/handlers/monitoring/health.handler.ts @@ -4,6 +4,7 @@ import { joinPath } from "#veryfront/utils/path-utils.ts"; import { HTTP_OK, HTTP_UNAVAILABLE, PRIORITY_HIGH } from "#veryfront/utils/constants/index.ts"; import { isTracingDegraded, isTracingEnabled } from "#veryfront/observability"; import { RUNTIME_VERSION } from "#veryfront/utils/version.ts"; +import { DEPENDENCY_ARTIFACT_BUILD_CAPABILITY } from "#veryfront/release-assets/dependency-artifact-contracts.ts"; let serverInitialized = false; @@ -74,6 +75,7 @@ export class HealthHandler extends BaseHandler { timestamp: new Date().toISOString(), mode: hasStaticBuild ? "static+ssr" : "ssr", version: RUNTIME_VERSION, + capabilities: [DEPENDENCY_ARTIFACT_BUILD_CAPABILITY], tracing: { enabled: isTracingEnabled(), degraded: tracingDegraded, diff --git a/src/server/handlers/monitoring/health.test.ts b/src/server/handlers/monitoring/health.test.ts index 555510a12e..c2b742ea4f 100644 --- a/src/server/handlers/monitoring/health.test.ts +++ b/src/server/handlers/monitoring/health.test.ts @@ -1,6 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; +import { DEPENDENCY_ARTIFACT_BUILD_CAPABILITY } from "#veryfront/release-assets/dependency-artifact-contracts.ts"; import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { HandlerContext } from "../types.ts"; import { HealthHandler, isServerInitialized, setServerInitialized } from "./health.handler.ts"; describe("server/handlers/monitoring/health", () => { @@ -50,5 +52,20 @@ describe("server/handlers/monitoring/health", () => { assertEquals(pattern.exact, true); } }); + + it("advertises the dependency artifact builder task capability", async () => { + const handler = new HealthHandler(); + const ctx = { + adapter: { fs: { stat: async () => null } }, + projectDir: "/project", + securityConfig: undefined, + } as unknown as HandlerContext; + + const result = await handler.handle(new Request("https://example.com/_health"), ctx); + + assertExists(result.response); + const body = await result.response.json() as { capabilities?: string[] }; + assertEquals(body.capabilities, [DEPENDENCY_ARTIFACT_BUILD_CAPABILITY]); + }); }); }); diff --git a/src/server/handlers/request/project-run-execute.handler.test.ts b/src/server/handlers/request/project-run-execute.handler.test.ts index 8897092214..267bdf3d34 100644 --- a/src/server/handlers/request/project-run-execute.handler.test.ts +++ b/src/server/handlers/request/project-run-execute.handler.test.ts @@ -153,6 +153,12 @@ function createDeps( logs: null, duration_ms: 10, }), + executeDependencyArtifactBuild: async () => ({ + success: true, + result: { state: "ready", assetCount: 2 }, + logs: null, + duration_ms: 11, + }), executeStyleArtifactBuild: async () => ({ success: true, result: { @@ -584,6 +590,63 @@ describe("server/handlers/request/project-run-execute.handler", () => { assertEquals(attemptedProjectDiscovery, false); }); + it("dispatches dependency artifact builds without project discovery", async () => { + let receivedConfig: Record | undefined; + let attemptedProjectDiscovery = false; + const handler = new ProjectRunExecuteHandler(createDeps({ + ensureProjectDiscovery: async () => { + attemptedProjectDiscovery = true; + return createEmptyDiscoveryResult(); + }, + executeDependencyArtifactBuild: async (input) => { + receivedConfig = input.request.config; + return { + success: true, + result: { state: "ready", assetCount: 2 }, + logs: null, + duration_ms: 11, + }; + }, + })); + const config = { + artifact_id: "11111111-1111-4111-8111-111111111111", + attempt_count: 1, + identity: { + origin_key: "npm:public", + package_name: "fixture-package", + exact_version: "1.2.3", + subpath: "", + target: "es2022", + profile: "standard-v1", + }, + policy: { decision: "allow" }, + }; + const body = { + runId: "run_dependency_artifact_1", + kind: "task", + target: "task:dependency-artifact-build", + projectId: "proj-1", + config, + }; + const { request, publicKeyPem } = await signedRequest( + "/api/control-plane/runs/run_dependency_artifact_1/execute", + body, + ); + + const result = await handler.handle(request, createCtx(publicKeyPem)); + + assertExists(result.response); + assertEquals(result.response.status, 200); + assertEquals(await result.response.json(), { + success: true, + result: { state: "ready", assetCount: 2 }, + logs: null, + duration_ms: 11, + }); + assertEquals(receivedConfig, config); + assertEquals(attemptedProjectDiscovery, false); + }); + it("builds style artifacts from adapter source files and adapter stylesheet reads", async () => { const body = { runId: "run_style_artifact_adapter_source", diff --git a/src/server/handlers/request/project-run-execute.handler.ts b/src/server/handlers/request/project-run-execute.handler.ts index b62c04bd50..d90e5a3add 100644 --- a/src/server/handlers/request/project-run-execute.handler.ts +++ b/src/server/handlers/request/project-run-execute.handler.ts @@ -151,6 +151,11 @@ export interface ProjectRunExecuteHandlerDeps { ctx: HandlerContext; req: Request; }): Promise; + executeDependencyArtifactBuild(input: { + request: ProjectRunExecuteRequest; + ctx: HandlerContext; + req: Request; + }): Promise; executeStyleArtifactBuild(input: { request: ProjectRunExecuteRequest; ctx: HandlerContext; @@ -1182,6 +1187,66 @@ async function executeReleaseAssetBuildRun(input: { } } +async function executeDependencyArtifactBuildRun(input: { + request: ProjectRunExecuteRequest; + ctx: HandlerContext; + req: Request; +}): Promise { + const startedAt = Date.now(); + try { + const { + parseDependencyArtifactBuildTaskInput, + runDependencyArtifactBuild, + } = await import("#veryfront/release-assets/dependency-artifact-builder.ts"); + const taskInput = parseDependencyArtifactBuildTaskInput(input.request.config); + const token = getRuntimeApiToken(input.req, input.ctx); + if (!token) { + throw INVALID_ARGUMENT.create({ detail: "Missing project runtime API token" }); + } + + const { VeryfrontApiClient } = await import( + "#veryfront/platform/adapters/veryfront-api-client/client.ts" + ); + const apiClient = new VeryfrontApiClient({ + apiBaseUrl: getEnvironmentConfig().apiBaseUrl, + apiToken: token, + projectSlug: input.ctx.projectSlug, + projectId: input.ctx.projectId, + }); + const result = await runDependencyArtifactBuild(taskInput, { + uploadAsset: ({ artifactId, attemptCount, contentHash, contentType, bytes }) => + apiClient.uploadDependencyArtifactAsset( + artifactId, + attemptCount, + contentHash, + contentType, + bytes, + ), + reportResult: ({ artifactId, attemptCount, result }) => + apiClient.reportDependencyArtifactBuildResult( + artifactId, + attemptCount, + result, + ), + }); + + return { + success: result.success, + result, + ...(result.success ? {} : { error: result.failureCode }), + logs: null, + duration_ms: Date.now() - startedAt, + }; + } catch (error) { + return { + success: false, + error: errorMessage(error), + logs: null, + duration_ms: Date.now() - startedAt, + }; + } +} + type StyleArtifactBuildSelector = { branch?: string; environmentName?: string; @@ -1462,6 +1527,7 @@ const defaultDeps: ProjectRunExecuteHandlerDeps = { ensureProjectDiscovery, executeKnowledgeIngest: executeKnowledgeIngestRun, executeReleaseAssetBuild: executeReleaseAssetBuildRun, + executeDependencyArtifactBuild: executeDependencyArtifactBuildRun, executeStyleArtifactBuild: executeStyleArtifactBuildRun, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => Date.now(), @@ -1519,6 +1585,8 @@ export class ProjectRunExecuteHandler extends BaseHandler { ? await this.deps.executeKnowledgeIngest({ request, ctx, req }) : request.kind === "task" && request.target === "task:release-asset-build" ? await this.deps.executeReleaseAssetBuild({ request, ctx, req }) + : request.kind === "task" && request.target === "task:dependency-artifact-build" + ? await this.deps.executeDependencyArtifactBuild({ request, ctx, req }) : request.kind === "task" && request.target === "task:style-artifact-build" ? await this.deps.executeStyleArtifactBuild({ request, ctx, req }) : request.kind === "task"