Ctrl K
ComunidadGuías…

scripts/static-cdn.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { sha256Hex } from "#shared/sha256.ts";

export interface StaticCdnConfig {
  accountKey: string;
  cdnUrl: string;
  pullZoneId: string;
  storageHost: string;
  storageKey: string;
  storageName: string;
}

export interface StaticCdnAsset {
  bytes: Uint8Array;
  contentType: string;
  filename: string;
}

export interface PublishedStaticAssets {
  origin: string;
  urls: Record<string, string>;
}

const CDN_ENV_KEYS = [
  "CDN_URL",
  "CDN_BUNNY_STORAGE_ZONE_NAME",
  "CDN_BUNNY_STORAGE_ZONE_KEY",
  "CDN_BUNNY_STORAGE_HOST",
  "CDN_BUNNY_PULL_ZONE_ID",
] as const;
const BUNNY_STORAGE_HOSTS = [
  "storage.bunnycdn.com",
  "uk.storage.bunnycdn.com",
  "ny.storage.bunnycdn.com",
  "la.storage.bunnycdn.com",
  "sg.storage.bunnycdn.com",
  "se.storage.bunnycdn.com",
  "br.storage.bunnycdn.com",
  "jh.storage.bunnycdn.com",
  "syd.storage.bunnycdn.com",
] as const;
export const STATIC_CDN_REQUEST_TIMEOUT_MS = 30_000;

export const cleanCdnUrl = (raw: string): string => {
  const url = new URL(raw);
  if (url.protocol !== "https:") throw new Error("CDN_URL must use HTTPS");
  if (url.username || url.password || url.search || url.hash) {
    throw new Error("CDN_URL must be a clean HTTPS base URL");
  }
  return url.toString().replace(/\/+$/, "");
};

export const loadStaticCdnConfig = (
  env: Record<string, string | undefined>,
): StaticCdnConfig | null => {
  const values = CDN_ENV_KEYS.map((key) => {
    const value = env[key];
    return value === undefined ? "" : value.trim();
  });
  const present = values.filter(Boolean).length;
  if (present === 0) return null;
  if (present !== CDN_ENV_KEYS.length) {
    throw new Error(`${CDN_ENV_KEYS.join(", ")} must all be set together`);
  }
  const rawAccountKey = env.BUNNY_ACCESS_KEY;
  if (rawAccountKey === undefined) {
    throw new Error("BUNNY_ACCESS_KEY is required to purge the static CDN");
  }
  const accountKey = rawAccountKey.trim();
  if (accountKey === "") {
    throw new Error("BUNNY_ACCESS_KEY is required to purge the static CDN");
  }

  const [cdnUrl, storageName, storageKey, storageHost, pullZoneId] = values as [
    string,
    string,
    string,
    string,
    string,
  ];
  if (!BUNNY_STORAGE_HOSTS.some((host) => host === storageHost)) {
    throw new Error(
      `CDN_BUNNY_STORAGE_HOST must be one of: ${BUNNY_STORAGE_HOSTS.join(", ")}`,
    );
  }
  if (!/^[A-Za-z0-9_-]+$/.test(storageName)) {
    throw new Error("CDN_BUNNY_STORAGE_ZONE_NAME must be a storage zone name");
  }
  if (!/^\d+$/.test(pullZoneId)) {
    throw new Error("CDN_BUNNY_PULL_ZONE_ID must be numeric");
  }
  return {
    accountKey,
    cdnUrl: cleanCdnUrl(cdnUrl),
    pullZoneId,
    storageHost,
    storageKey,
    storageName,
  };
};

const checkedFetch = async (
  fetcher: typeof fetch,
  input: string,
  init: RequestInit | undefined,
  failure: string,
): Promise<Response> => {
  const signals = [
    AbortSignal.timeout(STATIC_CDN_REQUEST_TIMEOUT_MS),
    init?.signal,
  ].filter(
    (signal): signal is AbortSignal => signal !== null && signal !== undefined,
  );
  const response = await fetcher(input, {
    ...init,
    signal: AbortSignal.any(signals),
  });
  if (!response.ok) {
    throw new Error(`${failure}: HTTP ${response.status}`);
  }
  return response;
};

const purge = async (
  config: StaticCdnConfig,
  fetcher: typeof fetch,
): Promise<void> => {
  await checkedFetch(
    fetcher,
    `https://api.bunny.net/pullzone/${config.pullZoneId}/purgeCache`,
    {
      headers: { AccessKey: config.accountKey },
      method: "POST",
    },
    "Failed to purge static CDN",
  );
};

const releaseHash = async (assets: StaticCdnAsset[]): Promise<string> => {
  const encoder = new TextEncoder();
  const sorted = assets.toSorted((a, b) =>
    a.filename.localeCompare(b.filename),
  );
  const parts = sorted.flatMap((asset) => [
    encoder.encode(
      `${asset.filename}\0${asset.contentType}\0${asset.bytes.length}\0`,
    ),
    asset.bytes,
  ]);
  const bytes = await new Blob(
    parts.map((part) => Uint8Array.from(part).buffer),
  ).arrayBuffer();
  return sha256Hex(new Uint8Array(bytes));
};

const publicUrl = (
  config: StaticCdnConfig,
  releasePath: string,
  filename: string,
): string => `${config.cdnUrl}/${releasePath}/${filename}`;

/** A media type without its parameters, lower-cased for comparison. */
const bareMediaType = (value: string): string =>
  value.replace(/;.*$/, "").trim().toLowerCase();

/** The per-asset upload and verify steps for one publish run, bound to its
 * config and fetch so the two steps share their plumbing — the request path
 * and the "Failed to <action> static CDN asset <name>" failure shape. */
const createAssetSteps = (config: StaticCdnConfig, fetcher: typeof fetch) => {
  const fetchAssetOrThrow = (
    action: "upload" | "verify",
    asset: StaticCdnAsset,
    url: string,
    init?: RequestInit,
  ): Promise<Response> =>
    checkedFetch(
      fetcher,
      url,
      init,
      `Failed to ${action} static CDN asset ${asset.filename}`,
    );

  const upload = async (
    objectPath: string,
    asset: StaticCdnAsset,
  ): Promise<void> => {
    await fetchAssetOrThrow(
      "upload",
      asset,
      `https://${config.storageHost}/${config.storageName}/${objectPath}`,
      {
        body: asset.bytes as BodyInit,
        headers: {
          AccessKey: config.storageKey,
          Checksum: (await sha256Hex(asset.bytes)).toUpperCase(),
          "Content-Type": asset.contentType,
        },
        method: "PUT",
      },
    );
  };

  const verify = async (url: string, asset: StaticCdnAsset): Promise<void> => {
    const response = await fetchAssetOrThrow("verify", asset, url);
    const contentType = response.headers.get("content-type");
    if (contentType === null) {
      throw new Error(
        `Static CDN asset ${asset.filename} is missing its content type`,
      );
    }
    const servedMediaType = bareMediaType(contentType);
    const expectedMediaType = bareMediaType(asset.contentType);
    if (servedMediaType !== expectedMediaType) {
      throw new Error(
        `Static CDN asset ${asset.filename} has content type ${servedMediaType}; expected ${expectedMediaType}`,
      );
    }
    const served = new Uint8Array(await response.arrayBuffer());
    if ((await sha256Hex(served)) !== (await sha256Hex(asset.bytes))) {
      throw new Error(
        `Static CDN asset ${asset.filename} does not match the uploaded file`,
      );
    }
  };

  return { upload, verify };
};

/** Run one per-asset step (upload or verify) over every asset in parallel. */
const forEveryAsset = async (
  assets: StaticCdnAsset[],
  step: (asset: StaticCdnAsset) => Promise<void>,
): Promise<void> => {
  await Promise.all(assets.map(step));
};

export const publishStaticCdnAssets = async (
  config: StaticCdnConfig,
  assets: StaticCdnAsset[],
  fetcher: typeof fetch = fetch,
): Promise<PublishedStaticAssets> => {
  const filenames = new Set(assets.map((asset) => asset.filename));
  if (filenames.size !== assets.length) {
    throw new Error("Static CDN asset filenames must be unique");
  }
  const version = await releaseHash(assets);
  const releasePath = `assets/${version}`;
  const storageBase = new URL(config.cdnUrl).pathname.replace(/^\/|\/$/g, "");
  const storagePath = storageBase
    ? `${storageBase}/${releasePath}`
    : releasePath;
  const steps = createAssetSteps(config, fetcher);
  await forEveryAsset(assets, (asset) =>
    steps.upload(`${storagePath}/${asset.filename}`, asset),
  );
  await purge(config, fetcher);
  await forEveryAsset(assets, (asset) =>
    steps.verify(publicUrl(config, releasePath, asset.filename), asset),
  );
  return {
    origin: new URL(config.cdnUrl).origin,
    urls: Object.fromEntries(
      assets.map((asset) => [
        asset.filename,
        publicUrl(config, releasePath, asset.filename),
      ]),
    ),
  };
};