@kungfu-tech/buildchain 2.8.15 → 2.8.16-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,6 +213,23 @@ function joinS3Key(...parts) {
213
213
  return normalizeS3Key(parts.filter(Boolean).join("/"));
214
214
  }
215
215
 
216
+ function joinUrlPath(...parts) {
217
+ const raw = parts.join("/");
218
+ const normalized = normalizeS3Key(raw);
219
+ if (!normalized) {
220
+ return "/";
221
+ }
222
+ return raw.endsWith("/") ? `/${normalized}/` : `/${normalized}`;
223
+ }
224
+
225
+ function urlWithPath(baseUrl, requestPath) {
226
+ const url = new URL(baseUrl);
227
+ url.pathname = joinUrlPath(requestPath);
228
+ url.search = "";
229
+ url.hash = "";
230
+ return url.toString();
231
+ }
232
+
216
233
  function s3Uri(bucket, key = "") {
217
234
  if (!bucket) {
218
235
  throw new Error("aws-s3-cloudfront adapter requires a bucket or target");
@@ -239,6 +256,19 @@ function viewerWildcardPath(binding) {
239
256
  }
240
257
  }
241
258
 
259
+ function surfaceArtifactPrefix(binding) {
260
+ return normalizeS3Key(binding.sourcePath);
261
+ }
262
+
263
+ function surfaceArtifactRootFor({ artifactRoot, binding }) {
264
+ const prefix = normalizeS3Key(binding.artifactPathPrefix || surfaceArtifactPrefix(binding));
265
+ const root = prefix ? path.join(artifactRoot, prefix) : artifactRoot;
266
+ if (fs.existsSync(root)) {
267
+ return root;
268
+ }
269
+ return artifactRoot;
270
+ }
271
+
242
272
  function syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix }) {
243
273
  const args = ["s3", "sync", artifactRoot, s3Uri(bucket, objectPrefix), "--delete"];
244
274
  if (!objectPrefix) {
@@ -528,6 +558,10 @@ function resolveSurfaceBindings({ config, channelName, alias, deployConfig }) {
528
558
  alias,
529
559
  url,
530
560
  sourcePath: normalizeSurfacePath(surface.path),
561
+ artifactPathPrefix: surfaceArtifactPrefix({ sourcePath: surface.path }),
562
+ viewerPathPrefix: "/",
563
+ directoryIndex: "index.html",
564
+ directoryIndexResolution: true,
531
565
  canonicalUrl: surface.productionUrl || (channelName === "production" ? url : ""),
532
566
  pathOnly: Boolean(surface.pathOnly),
533
567
  bucket,
@@ -544,6 +578,91 @@ function resolveSurfaceBindings({ config, channelName, alias, deployConfig }) {
544
578
  }));
545
579
  }
546
580
 
581
+ function relativeArtifactPath({ artifactPath, filePath }) {
582
+ const normalizedArtifact = normalizeS3Key(artifactPath);
583
+ const normalizedFile = normalizeS3Key(filePath);
584
+ if (!normalizedArtifact) {
585
+ return normalizedFile;
586
+ }
587
+ return normalizedFile === normalizedArtifact
588
+ ? ""
589
+ : normalizedFile.startsWith(`${normalizedArtifact}/`)
590
+ ? normalizedFile.slice(normalizedArtifact.length + 1)
591
+ : normalizedFile;
592
+ }
593
+
594
+ function requestPathFromArtifactPath({ binding, artifactPath, filePath }) {
595
+ const relative = relativeArtifactPath({ artifactPath, filePath });
596
+ const prefix = normalizeS3Key(binding.artifactPathPrefix || surfaceArtifactPrefix(binding));
597
+ if (prefix && relative !== prefix && !relative.startsWith(`${prefix}/`)) {
598
+ return "";
599
+ }
600
+ const surfaceRelative = prefix
601
+ ? relative.slice(prefix.length).replace(/^\/+/, "")
602
+ : relative;
603
+ if (!surfaceRelative || surfaceRelative === "index.html") {
604
+ return "/";
605
+ }
606
+ if (surfaceRelative.endsWith("/index.html")) {
607
+ const directoryPath = normalizeS3Key(surfaceRelative.slice(0, -"index.html".length));
608
+ return directoryPath ? `/${directoryPath}/` : "/";
609
+ }
610
+ return joinUrlPath(surfaceRelative);
611
+ }
612
+
613
+ function smokeUrlsForBinding({ binding, artifactPath, files }) {
614
+ const rootUrl = urlWithPath(binding.url, "/");
615
+ const candidates = (files || [])
616
+ .map((file) => requestPathFromArtifactPath({ binding, artifactPath, filePath: file.path }))
617
+ .filter(Boolean)
618
+ .filter((requestPath) => requestPath !== "/")
619
+ .filter((requestPath) => requestPath.endsWith("/") || requestPath.endsWith(".html"))
620
+ .sort();
621
+ const nestedPath = candidates[0] || "";
622
+ return [
623
+ {
624
+ kind: "root",
625
+ requestPath: "/",
626
+ url: rootUrl,
627
+ required: true,
628
+ },
629
+ ...(nestedPath
630
+ ? [{
631
+ kind: "nested",
632
+ requestPath: nestedPath,
633
+ url: urlWithPath(binding.url, nestedPath),
634
+ required: true,
635
+ }]
636
+ : [{
637
+ kind: "nested",
638
+ requestPath: "",
639
+ url: "",
640
+ required: true,
641
+ missing: true,
642
+ message: "no nested HTML route was found under this surface artifact prefix",
643
+ }]),
644
+ ];
645
+ }
646
+
647
+ function withSurfaceRoutingEvidence(bindings, { artifactPath, files }) {
648
+ return bindings.map((binding) => ({
649
+ ...binding,
650
+ artifactPathPrefix: normalizeS3Key(binding.artifactPathPrefix || surfaceArtifactPrefix(binding)),
651
+ viewerPathPrefix: binding.viewerPathPrefix || "/",
652
+ directoryIndex: binding.directoryIndex || "index.html",
653
+ directoryIndexResolution: binding.directoryIndexResolution !== false,
654
+ routing: {
655
+ contract: "kungfu-buildchain-web-surface-path-prefix-rewrite",
656
+ viewerPathPrefix: binding.viewerPathPrefix || "/",
657
+ artifactPathPrefix: normalizeS3Key(binding.artifactPathPrefix || surfaceArtifactPrefix(binding)),
658
+ objectPrefix: binding.objectPrefix,
659
+ directoryIndex: binding.directoryIndex || "index.html",
660
+ directoryIndexResolution: binding.directoryIndexResolution !== false,
661
+ },
662
+ smokeUrls: smokeUrlsForBinding({ binding, artifactPath, files }),
663
+ }));
664
+ }
665
+
547
666
  export function validateWebSurfaceProject(cwd = process.cwd()) {
548
667
  const summary = validateBuildchainConfig(cwd, {
549
668
  requireConfig: true,
@@ -706,6 +825,11 @@ export function planWebSurfaceDeploy({
706
825
  rollbackLimitations,
707
826
  deployedAt,
708
827
  });
828
+ const surfaceBindings = withSurfaceRoutingEvidence(manifest.surfaceBindings, {
829
+ artifactPath: artifactPath || deployConfig.artifactPath || ".",
830
+ files: resolvedArtifact.files,
831
+ });
832
+ manifest.surfaceBindings = surfaceBindings;
709
833
  return {
710
834
  schemaVersion: 1,
711
835
  contract: "kungfu-buildchain-web-surface-deploy-plan",
@@ -714,14 +838,14 @@ export function planWebSurfaceDeploy({
714
838
  channel,
715
839
  alias,
716
840
  url: manifest.url,
717
- urls: Object.fromEntries(manifest.surfaceBindings.map((binding) => [binding.surface, binding.url])),
841
+ urls: Object.fromEntries(surfaceBindings.map((binding) => [binding.surface, binding.url])),
718
842
  artifact: {
719
843
  path: artifactPath || deployConfig.artifactPath || ".",
720
844
  hash: resolvedArtifact.artifactHash,
721
845
  files: resolvedArtifact.files,
722
846
  },
723
847
  manifest,
724
- surfaceBindings: manifest.surfaceBindings,
848
+ surfaceBindings,
725
849
  steps: planAdapterSteps(deployConfig.adapter, deployConfig, manifest),
726
850
  };
727
851
  }
@@ -763,7 +887,11 @@ export function applyWebSurfaceDeploy({
763
887
  }
764
888
  const bucket = deployConfig.bucket || deployConfig.target || "";
765
889
  const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
766
- const bindings = resolvedPlan.manifest.surfaceBindings || [];
890
+ const bindings = withSurfaceRoutingEvidence(resolvedPlan.manifest.surfaceBindings || [], {
891
+ artifactPath: resolvedPlan.artifact.path,
892
+ files: resolvedPlan.artifact.files || [],
893
+ });
894
+ resolvedPlan.manifest.surfaceBindings = bindings;
767
895
  if (!dryRun) {
768
896
  for (const binding of bindings) {
769
897
  assertConcreteAwsDeployConfig({
@@ -1103,7 +1231,45 @@ export async function checkWebSurfaceHealth({
1103
1231
  });
1104
1232
  }
1105
1233
 
1106
- for (const [surface, url] of Object.entries(urls)) {
1234
+ const smokeTargets = bindings.length > 0
1235
+ ? bindings.flatMap((binding) => {
1236
+ const smokeUrls = Array.isArray(binding.smokeUrls) && binding.smokeUrls.length > 0
1237
+ ? binding.smokeUrls
1238
+ : [{ kind: "root", requestPath: "/", url: binding.url || urls[binding.surface] || "", required: true }];
1239
+ return smokeUrls.map((smoke) => ({
1240
+ surface: binding.surface,
1241
+ kind: smoke.kind || "root",
1242
+ requestPath: smoke.requestPath || "",
1243
+ url: smoke.url || "",
1244
+ required: smoke.required !== false,
1245
+ missing: Boolean(smoke.missing),
1246
+ message: smoke.message || "",
1247
+ }));
1248
+ })
1249
+ : Object.entries(urls).map(([surface, url]) => ({
1250
+ surface,
1251
+ kind: "root",
1252
+ requestPath: "/",
1253
+ url,
1254
+ required: true,
1255
+ }));
1256
+
1257
+ for (const target of smokeTargets) {
1258
+ const { surface, url } = target;
1259
+ if (!url || target.missing) {
1260
+ checks.push({
1261
+ surface,
1262
+ kind: target.kind,
1263
+ requestPath: target.requestPath,
1264
+ url: url || "",
1265
+ status: "fail",
1266
+ httpStatus: 0,
1267
+ finalUrl: "",
1268
+ noindexHeader: false,
1269
+ message: target.message || "web-surface smoke URL is missing",
1270
+ });
1271
+ continue;
1272
+ }
1107
1273
  try {
1108
1274
  const response = await fetchImpl(url, { redirect: "follow" });
1109
1275
  const expectedNoindex = Boolean(config.channels?.[channel]?.noindex);
@@ -1117,6 +1283,8 @@ export async function checkWebSurfaceHealth({
1117
1283
  const noindexOk = channel !== "production" || expectedNoindex || !noindex;
1118
1284
  checks.push({
1119
1285
  surface,
1286
+ kind: target.kind,
1287
+ requestPath: target.requestPath,
1120
1288
  url,
1121
1289
  status: statusOk && noindexOk ? "pass" : "fail",
1122
1290
  httpStatus: response.status,
@@ -1131,6 +1299,8 @@ export async function checkWebSurfaceHealth({
1131
1299
  } catch (error) {
1132
1300
  checks.push({
1133
1301
  surface,
1302
+ kind: target.kind,
1303
+ requestPath: target.requestPath,
1134
1304
  url,
1135
1305
  status: "fail",
1136
1306
  httpStatus: 0,
@@ -1173,12 +1343,14 @@ function deployBindingOperations({ artifactRoot, deployConfig, manifest, binding
1173
1343
  const effectiveDeploy = surfaceDeployConfig(deployConfig, binding.surface);
1174
1344
  const bucket = binding.bucket || effectiveDeploy.bucket || effectiveDeploy.target || "";
1175
1345
  const distribution = binding.distributionId || effectiveDeploy.cloudfront_distribution || effectiveDeploy.distribution || "";
1346
+ const surfaceArtifactRoot = surfaceArtifactRootFor({ artifactRoot, binding });
1176
1347
  const operations = [
1177
1348
  {
1178
1349
  action: "sync-static-artifact",
1179
1350
  surface: binding.surface,
1180
1351
  command: "aws",
1181
- args: syncStaticArtifactArgs({ artifactRoot, bucket, objectPrefix: binding.objectPrefix }),
1352
+ args: syncStaticArtifactArgs({ artifactRoot: surfaceArtifactRoot, bucket, objectPrefix: binding.objectPrefix }),
1353
+ routing: binding.routing,
1182
1354
  },
1183
1355
  {
1184
1356
  action: "write-deployment-manifest",