run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
702 lines • 34.7 kB
JavaScript
/**
* `blobs` namespace — direct-to-S3 blob storage.
*
* `put` encapsulates the 3-step upload flow (init → PUT parts to S3 →
* complete). The S3 PUT uses `client.fetch` directly (not the gateway's
* `request`) so it bypasses gateway auth and path-rewriting. The client's
* configured fetch still flows through any wrappers (e.g. test mocks).
*/
import { ApiError, LocalError } from "../errors.js";
import { requireProjectCredentials } from "../project-credentials.js";
import { assertPositiveSafeInteger, isKnownBinaryContent } from "../validation.js";
import { appendAssetFilterTo, assertAssetFilter, assertAssetMetadata, assertAssetSortKey, assertExifPolicy, } from "./assets-validation.js";
function encodeKey(key) {
return key.split("/").map(encodeURIComponent).join("/");
}
function guessContentType(key) {
const ext = key.slice(key.lastIndexOf(".") + 1).toLowerCase();
const map = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
webp: "image/webp",
html: "text/html",
css: "text/css",
js: "text/javascript",
json: "application/json",
txt: "text/plain",
md: "text/markdown",
pdf: "application/pdf",
zip: "application/zip",
tgz: "application/gzip",
gz: "application/gzip",
};
return map[ext] ?? "application/octet-stream";
}
async function sha256Hex(bytes) {
// crypto.subtle.digest typings disallow SharedArrayBuffer-backed views;
// cast through BufferSource to accept the concrete Uint8Array instance.
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function sha256HexAndBase64(bytes) {
const hex = await sha256Hex(bytes);
return { hex, base64: hexToBase64(hex) };
}
function normalizeBlobDiagnoseEnvelope(env) {
const cache = env.cache ?? {};
return {
projectId: env.projectId ?? env.project_id ?? "",
key: env.key ?? "",
expectedSha256: env.expectedSha256 ?? env.expected_sha256 ?? null,
observedSha256: env.observedSha256 ?? env.observed_sha256 ?? null,
vantage: env.vantage ?? "gateway-us-east-1",
probeMethod: env.probeMethod ?? env.probe_method ?? "GET_RANGE_0_0",
acceptEncoding: env.acceptEncoding ?? env.accept_encoding ?? "",
observedAt: env.observedAt ?? env.observed_at ?? "",
probeMayHaveWarmedCache: env.probeMayHaveWarmedCache ?? env.probe_may_have_warmed_cache ?? true,
canonicalUrl: env.canonicalUrl ?? env.canonical_url ?? "",
pathKind: env.pathKind ?? env.path_kind ?? "blob-mutable",
cache: {
xCache: cache.xCache ?? cache.x_cache ?? null,
ageSeconds: cache.ageSeconds ?? cache.age_seconds ?? null,
cacheKind: cache.cacheKind ?? cache.cache_kind ?? null,
},
invalidation: env.invalidation ?? { id: null, status: null },
hint: env.hint ?? "",
};
}
function validateSha256Hex(value, name, context) {
if (typeof value !== "string" || !/^[a-fA-F0-9]{64}$/.test(value)) {
throw new LocalError(`${name} must be a 64-character hex SHA-256 digest.`, context);
}
}
function assertIntegerInRange(value, name, min, max, context) {
if (!Number.isSafeInteger(value) || value < min || value > max) {
throw new LocalError(`${name} must be a safe integer between ${min} and ${max}.`, context);
}
}
function checksumHeadersForPresignedUrl(url, checksumBase64) {
let urlHasChecksum = false;
try {
urlHasChecksum = new URL(url).searchParams.has("x-amz-checksum-sha256");
}
catch {
urlHasChecksum = false;
}
return urlHasChecksum ? {} : { "x-amz-checksum-sha256": checksumBase64 };
}
function escapeHtmlAttr(s) {
return s
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
}
/**
* Convert hex (e.g. `"abcd…"`) to base64 in environments that have either
* Buffer (Node) or `btoa` (browsers). The helper is here because Browser SDK
* builds may run without `Buffer`.
*/
function hexToBase64(hex) {
const len = hex.length;
const bytes = new Uint8Array(len / 2);
for (let i = 0; i < len; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
// Prefer Buffer when available (faster on Node).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis;
if (g.Buffer)
return g.Buffer.from(bytes).toString("base64");
let s = "";
for (const b of bytes)
s += String.fromCharCode(b);
return g.btoa ? g.btoa(s) : "";
}
/**
* Widen the gateway's snake_case completion response into the AssetRef
* shape (which adds camelCase aliases + locally-derived integrity fields
* for any URL the consumer is about to embed in code). For non-immutable
* uploads where the SHA is null, the integrity fields are null and the
* agent guidance steers them to use `--immutable` before linking.
*/
function buildAssetRef(resp, contentType) {
const sha = resp.sha256;
const immutable = !!resp.immutable_url;
const cacheKind = resp.visibility === "private"
? "private"
: immutable
? "immutable"
: "mutable";
const etag = sha ? `"sha256-${sha}"` : null;
const sri = sha ? `sha256-${hexToBase64(sha)}` : null;
const contentDigest = sha ? `sha-256=:${hexToBase64(sha)}:` : null;
// v1.45 agent-DX URLs — guaranteed CDN-reachable on the auto-subdomain.
// Older gateway versions don't emit them; null in that case (callers fall
// back to the preferred-host `url` / `immutableUrl`).
const cdnUrl = resp.cdn_immutable_url ?? null;
const cdnMutableUrl = resp.cdn_url ?? null;
// The cdn envelope: prefer what the gateway returns; fall back to
// best-effort defaults so older gateway versions don't break the SDK
// surface. immutable URLs are always-ready by definition.
const cdnFromGw = resp.cdn ?? {};
const cdn = {
version: cdnFromGw.version ?? "blob-gateway-v2",
invalidationId: cdnFromGw.invalidationId ?? null,
invalidationStatus: cdnFromGw.invalidationStatus ?? null,
ready: cdnFromGw.ready ?? immutable,
hint: cdnFromGw.hint ??
(immutable
? "Use cdnUrl + scriptTag()/linkTag()/imgTag() — paste-and-go."
: "For mutable URLs, propagation is asynchronous. Prefer cdnUrl (immutable, content-hashed) for generated HTML/CSS/JS, or call wait_for_cdn_freshness."),
};
// Emitter helpers. Throw with an actionable hint when the SHA is null
// (non-immutable upload) — the agent should re-upload with `immutable:
// true` to get content-hashed URLs that pair with SRI.
function requireImmutable(name) {
if (!cdnUrl || !sri) {
throw new LocalError(`${name}() requires an immutable upload (pass { immutable: true } to blobs.put). ` +
`Without immutable, there is no content-addressed CDN URL to bind for SRI and the URL would change on re-upload.`, "rendering blob tag");
}
return { url: cdnUrl, sri };
}
// v1.49+ image-variant convenience computation. The gateway signals
// "this is an image" by populating `variant_spec_version` (always) or
// `width_px` (always for image MIMEs, even sub-320). For non-images both
// are absent. We thread this signal into `thumbUrl` / `displayUrl` so
// TypeScript narrows correctly: non-image refs return `undefined` from
// both getters and a picker can't accidentally `<img src={pdfRef.thumbUrl}>`.
const isImage = resp.variant_spec_version !== undefined || resp.width_px !== undefined;
// Source dimensions are display-oriented (gateway runs EXIF auto-rotate).
const widthPx = resp.width_px;
const heightPx = resp.height_px;
const variants = resp.variants;
// For non-HEIC images, gateway sets display_url === cdn_url. For HEIC it
// points to the JPEG transcode. For sub-320 images that have no `variants`,
// display_url is still populated.
const displayUrl = isImage
? resp.display_url ?? resp.cdn_url ?? undefined
: undefined;
const thumbUrl = isImage
? variants?.thumb?.cdn_url ?? displayUrl
: undefined;
// v1.50: widen each new field to a nullable property. Pre-v1.50 gateways
// omit them; widening to `null` (instead of optional `undefined`) keeps
// the consumer JSON shape stable and lets callers branch on
// `result.image_format === null` without an extra existence check.
const metadataField = resp.metadata ?? null;
const imageFormatField = resp.image_format ?? null;
const imageInfoField = resp.image_info ?? null;
const imageExifField = resp.image_exif ?? null;
const imageExifPolicyField = resp.image_exif_policy ?? null;
return {
key: resp.key,
size_bytes: resp.size_bytes,
sha256: sha,
visibility: resp.visibility,
url: resp.url,
immutable_url: resp.immutable_url,
size: resp.size_bytes,
contentSha256: sha,
contentType,
immutableUrl: resp.immutable_url,
cdnUrl,
cdnMutableUrl,
etag,
sri,
contentDigest,
cacheKind,
cdn,
// v1.49+ image-variant fields. Only set when present on the wire — we
// intentionally omit (vs. setting to `undefined` explicitly) so the
// shape is byte-identical to pre-v1.49 for non-images.
...(resp.width_px !== undefined ? { width_px: resp.width_px } : {}),
...(resp.height_px !== undefined ? { height_px: resp.height_px } : {}),
...(resp.blurhash !== undefined ? { blurhash: resp.blurhash } : {}),
...(resp.variant_spec_version !== undefined
? { variant_spec_version: resp.variant_spec_version }
: {}),
...(resp.display_url !== undefined ? { display_url: resp.display_url } : {}),
...(resp.display_immutable_url !== undefined
? { display_immutable_url: resp.display_immutable_url }
: {}),
...(variants !== undefined ? { variants } : {}),
...(thumbUrl !== undefined ? { thumbUrl } : {}),
...(displayUrl !== undefined ? { displayUrl } : {}),
// v1.50: metadata + EXIF policy + image intrinsics (flat shape).
// Always present in the envelope; `null` for non-image uploads (and
// for pre-v1.50 gateway responses where the fields are absent on the
// wire).
metadata: metadataField,
image_format: imageFormatField,
image_info: imageInfoField,
image_exif: imageExifField,
image_exif_policy: imageExifPolicyField,
// v1.54: shape-contract fields. Omit-when-undefined so pre-v1.54
// envelopes stay bytewise-identical (no null injection).
...(resp.blurhash_data_url !== undefined
? { blurhash_data_url: resp.blurhash_data_url }
: {}),
...(resp.asset_schema !== undefined
? { asset_schema: resp.asset_schema }
: {}),
scriptTag(opts) {
// Default `defer: true` — modern best practice. Defer prevents
// render-blocking when placed in <head> and is a no-op when placed
// at the end of <body> (the script runs after DOMContentLoaded
// either way). Pass `{ defer: false }` to opt out for the rare
// case requiring synchronous execution. `async` and `defer` are
// mutually exclusive; passing async overrides defer.
const { url, sri } = requireImmutable("scriptTag");
const attrs = [`src="${escapeHtmlAttr(url)}"`];
if (opts?.type === "module")
attrs.push(`type="module"`);
const wantsAsync = opts?.async === true;
const wantsDefer = opts?.defer ?? !wantsAsync;
if (wantsAsync)
attrs.push("async");
else if (wantsDefer)
attrs.push("defer");
attrs.push(`integrity="${escapeHtmlAttr(sri)}"`);
attrs.push("crossorigin");
return `<script ${attrs.join(" ")}></script>`;
},
linkTag(opts) {
// Always emit crossorigin — required for SRI to actually be
// enforced. Without crossorigin the browser silently ignores the
// integrity attribute (HTML spec). This applies to rel="preload"
// too: matching crossorigin on the preload + the eventual fetch is
// what lets the browser dedupe instead of double-fetching.
const { url, sri } = requireImmutable("linkTag");
const rel = opts?.rel ?? "stylesheet";
const attrs = [`rel="${escapeHtmlAttr(rel)}"`];
attrs.push(`href="${escapeHtmlAttr(url)}"`);
if (opts?.as)
attrs.push(`as="${escapeHtmlAttr(opts.as)}"`);
attrs.push(`integrity="${escapeHtmlAttr(sri)}"`);
attrs.push("crossorigin");
return `<link ${attrs.join(" ")}>`;
},
imgTag(alt) {
// Defaults: loading="lazy" + decoding="async" — modern best
// practice. Lazy is harmless for above-fold images (browsers
// handle the heuristic) and a flat win for the much more common
// below-fold case. Async decoding moves the decode off the main
// thread. Both are baseline-supported in all major browsers.
// <img> doesn't accept SRI per HTML5; the URL is content-hashed
// so it's still stable across re-deploys. Agents who need
// byte-level integrity for images should verify Content-Digest
// server-side.
//
// v1.49+ HEIC handling: when the source is HEIC/HEIF, `cdn_url`
// serves the original (unrenderable) HEIC bytes and `display_url`
// serves a generated JPEG transcode. We default `<img src>` to
// `display_url ?? cdn_url` so HEIC uploads render without the
// caller knowing about it. For non-HEIC images `display_url ===
// cdn_url`, so this is a no-op there.
const { url: immutableCdnUrl } = requireImmutable("imgTag");
const src = displayUrl ?? immutableCdnUrl;
const a = alt ?? "";
const attrs = [`src="${escapeHtmlAttr(src)}"`, `alt="${escapeHtmlAttr(a)}"`];
// v1.49+ width/height emission. When both dimensions are known
// the browser reserves layout space and avoids Cumulative Layout
// Shift. Skip silently when either is missing (non-image, sub-320
// pre-v1.49 source, etc.) — `imgTag` never errors on absence.
if (widthPx !== undefined && heightPx !== undefined) {
attrs.push(`width="${widthPx}"`);
attrs.push(`height="${heightPx}"`);
}
attrs.push(`loading="lazy"`, `decoding="async"`);
return `<img ${attrs.join(" ")}>`;
},
imgTagWithSrcSet(opts) {
// Hard guard #1: opts.sizes must be a non-empty string. Without
// it, browsers conservatively download the largest candidate in
// srcset (defeating the variant set). Default-to-100vw would be
// wrong for any grid layout, so we throw and force the caller to
// think about sizes once instead of shipping a silent footgun.
if (!opts ||
typeof opts.sizes !== "string" ||
opts.sizes.trim() === "") {
throw new LocalError(`imgTagWithSrcSet requires opts.sizes (e.g. '(max-width: 800px) 100vw, 1920px') — browsers over-fetch the largest srcset candidate without it.`, "rendering responsive image");
}
// Hard guard #2: variants must include all three WebP sizes.
// Non-image refs, sub-320 images, and pre-v1.49 uploads land here
// with `variants` undefined or missing one of the three. Silent
// fallback would render a busted layout (no srcset, no responsive
// benefit) with no diagnostic, so we throw and tell the caller to
// use `imgTag()` instead.
const thumb = variants?.thumb;
const medium = variants?.medium;
const large = variants?.large;
if (!thumb || !medium || !large) {
throw new LocalError(`imgTagWithSrcSet called on an AssetRef without variants. Variants are generated only for image MIMEs ≥320×320 against a v1.49+ gateway. Use imgTag() instead for non-image refs, sub-320 images, or older gateway responses.`, "rendering responsive image");
}
// The <img> fallback must use display_url for HEIC sources
// (browsers can't render HEIC bytes). For non-HEIC images
// display_url === cdn_url. cdnUrl from the AssetRef widening is
// the immutable form; here we prefer display_url which the
// gateway already pinned to the right variant.
const fallbackSrc = displayUrl ?? cdnUrl ?? "";
const altAttr = `alt="${escapeHtmlAttr(opts.alt ?? "")}"`;
const loadingAttr = `loading="${opts.loading === "eager" ? "eager" : "lazy"}"`;
const srcset = [
`${thumb.cdn_url} ${thumb.width_px}w`,
`${medium.cdn_url} ${medium.width_px}w`,
`${large.cdn_url} ${large.width_px}w`,
].join(", ");
const sizes = opts.sizes;
const imgAttrs = [
`src="${escapeHtmlAttr(fallbackSrc)}"`,
altAttr,
];
// AVIF deferred from v1: <picture> picks sources by type
// precedence, not best size. A single 1920w AVIF would be picked
// for thumbnails by AVIF-capable browsers, defeating the variant
// set. AVIF, if it returns, will land at all three sizes or via
// a dedicated imgTagHero() helper.
if (widthPx !== undefined && heightPx !== undefined) {
imgAttrs.push(`width="${widthPx}"`);
imgAttrs.push(`height="${heightPx}"`);
}
imgAttrs.push(loadingAttr, `decoding="async"`);
const sourceLine = `<source type="image/webp" srcset="${escapeHtmlAttr(srcset)}" sizes="${escapeHtmlAttr(sizes)}">`;
const imgLine = `<img ${imgAttrs.join(" ")}>`;
return `<picture>${sourceLine}${imgLine}</picture>`;
},
};
}
export class Assets {
client;
constructor(client) {
this.client = client;
}
/**
* Upload a blob via the 3-step direct-to-S3 flow. The bytes are PUT to
* presigned S3 URLs — they do NOT pass through the gateway, so uploads
* are not double-billed as API calls and large files stream efficiently.
*
* Pass `immutable: true` to produce a content-addressed URL. The SDK always
* computes the SHA-256 digest required by the upload API; `immutable` only
* controls URL/cache semantics.
*
* @throws {ProjectNotFound} if `projectId` is not in the provider.
*/
async put(projectId, key, source, opts = {}) {
// Normalize the polymorphic source shape. Bare strings and
// Uint8Arrays are accepted as a shorthand for `{ content }` / `{ bytes }`
// so callers don't need to know about the wrapper object — every other
// ContentSource-shaped surface in the SDK accepts the bare form.
let normalized;
if (typeof source === "string") {
normalized = { content: source };
}
else if (source instanceof Uint8Array) {
normalized = { bytes: source };
}
else {
normalized = source;
}
if ((normalized.content !== undefined && normalized.bytes !== undefined) ||
(normalized.content === undefined && normalized.bytes === undefined)) {
throw new LocalError("Provide exactly one of `content` or `bytes` in BlobPutSource.", "uploading blob");
}
const contentType = opts.contentType ?? guessContentType(key);
if (normalized.content !== undefined && isKnownBinaryContent(key, contentType)) {
throw new LocalError(`Asset ${key} uses a JavaScript string for binary content (${contentType}). ` +
"Read the file without an encoding and pass Uint8Array or { bytes } so the original bytes are hashed.", "uploading blob", {
code: "BINARY_CONTENT_REQUIRES_BYTES",
details: { key, content_type: contentType },
});
}
const bytes = normalized.bytes
? normalized.bytes
: new TextEncoder().encode(normalized.content);
const sizeBytes = bytes.byteLength;
if (normalized.content !== undefined && sizeBytes > 1_048_576) {
throw new LocalError("`content` is limited to 1 MB. Use `bytes` for larger uploads.", "uploading blob");
}
// v1.45 default: `immutable: true`. The agent-DX surface (cdnUrl, sri,
// scriptTag/linkTag/imgTag) only works for content-addressed uploads.
const immutable = opts.immutable ?? true;
const visibility = opts.visibility ?? "public";
// v1.50: validate caller-supplied metadata + EXIF policy BEFORE any
// HTTP call. Errors throw LocalError with `code` set to the canonical
// gateway error code so consumers can branch on `e.code` regardless of
// whether the rejection happened locally or remotely.
if (opts.metadata !== undefined) {
assertAssetMetadata(opts.metadata, "uploading asset");
}
if (opts.exifPolicy !== undefined) {
assertExifPolicy(opts.exifPolicy, "uploading asset");
}
// Check credentials early so callers get ProjectNotFound rather than a
// generic error from inside the apply flow.
const projectKeys = await requireProjectCredentials(this.client, projectId, "uploading asset");
// v1.48 unified-apply: route through the apply hero. Bytes upload via
// /content/v1/plans (CAS substrate) and the asset slice promotes in the
// activation transaction of /apply/v1/plans/:plan_id/commit. The legacy
// /storage/v1/uploads* flow is gone (gateway returns 404).
const { Deploy } = await import("./deploy.js");
const deploy = new Deploy(this.client);
const result = await deploy.apply({
project_id: projectId,
assets: {
put: [
{
key,
source: bytes,
content_type: contentType,
visibility,
immutable,
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
...(opts.exifPolicy !== undefined ? { exifPolicy: opts.exifPolicy } : {}),
},
],
},
});
// The plan response's asset_entries[].asset_ref is threaded into
// result.assets.byKey by buildAssetManifestFromPlanEntries in deploy.ts.
const entry = result.assets?.byKey[key];
if (!entry) {
throw new LocalError(`apply succeeded but result.assets.byKey["${key}"] is missing. Gateway plan response did not include an asset_entries entry for the key — likely an older gateway version pre-v1.48.`, "uploading asset");
}
// Widen AssetManifestEntry → BlobPutResult (AssetRef shape with
// scriptTag/linkTag/imgTag emitters). The integrity fields and CDN
// envelope are derived from the same SHA the gateway resolved.
// v1.49+: image-variant fields (width_px/height_px/blurhash/variants/
// display_url/display_immutable_url/variant_spec_version) are
// threaded through `AssetManifestEntry` by
// `buildAssetManifestFromPlanEntries` in deploy.ts. They're optional
// and absent for non-image MIMEs.
const sha = entry.sha256;
const completion = {
key: entry.key,
size_bytes: entry.size_bytes,
sha256: sha,
visibility: entry.visibility,
url: entry.url,
immutable_url: entry.immutable_url,
content_type: entry.content_type,
cdn_url: entry.cdn_url,
cdn_immutable_url: entry.cdn_immutable_url,
immutable_suffix: null,
// v1.49+ image-variant pass-through. Only set when present so the
// shape stays bytewise-identical to pre-v1.49 for non-images.
...(entry.width_px !== undefined ? { width_px: entry.width_px } : {}),
...(entry.height_px !== undefined ? { height_px: entry.height_px } : {}),
...(entry.blurhash !== undefined ? { blurhash: entry.blurhash } : {}),
...(entry.variant_spec_version !== undefined
? { variant_spec_version: entry.variant_spec_version }
: {}),
...(entry.display_url !== undefined ? { display_url: entry.display_url } : {}),
...(entry.display_immutable_url !== undefined
? { display_immutable_url: entry.display_immutable_url }
: {}),
...(entry.variants !== undefined ? { variants: entry.variants } : {}),
// v1.50 metadata + EXIF policy + image intrinsics pass-through.
...(entry.metadata !== undefined ? { metadata: entry.metadata } : {}),
...(entry.image_format !== undefined ? { image_format: entry.image_format } : {}),
...(entry.image_info !== undefined ? { image_info: entry.image_info } : {}),
...(entry.image_exif !== undefined ? { image_exif: entry.image_exif } : {}),
...(entry.image_exif_policy !== undefined
? { image_exif_policy: entry.image_exif_policy }
: {}),
// v1.54 shape-contract pass-through.
...(entry.blurhash_data_url !== undefined
? { blurhash_data_url: entry.blurhash_data_url }
: {}),
...(entry.asset_schema !== undefined
? { asset_schema: entry.asset_schema }
: {}),
};
return buildAssetRef(completion, contentType);
}
/**
* Diagnose a public blob URL. Returns a JSON envelope describing the live
* CDN state (expected vs observed SHA, cache headers, recent invalidation
* status, vantage). The gateway probes the URL once from us-east-1 with
* `Range: bytes=0-0` and returns within 5 s even if the inner probe is
* slow.
*
* **Vantage caveat:** the result reflects ONE CloudFront PoP at the time
* of the call. Other PoPs may serve different cached states. The
* `probeMayHaveWarmedCache: true` field reminds the agent that the probe
* itself populates the cache, so a subsequent read may differ.
*
* The URL must belong to the requesting project — cross-project URLs are
* rejected by the gateway with `403`. SSRF is enforced gateway-side: only
* `*.run402.com` and the project's active custom domains are accepted.
*
* @example
* const diag = await client.blobs.diagnoseUrl("prj_abc", "https://app.run402.com/_blob/avatar.png");
* if (diag.observedSha256 !== diag.expectedSha256) console.log(diag.hint);
*/
async diagnoseUrl(projectId, url) {
const project = await requireProjectCredentials(this.client, projectId, "diagnosing blob URL");
const path = `/storage/v1/blobs/diagnose?url=${encodeURIComponent(url)}`;
const env = await this.client.request(path, {
headers: {
apikey: project.service_key,
Authorization: `Bearer ${project.service_key}`,
},
context: "diagnosing blob URL",
});
return normalizeBlobDiagnoseEnvelope(env);
}
/**
* Poll the CDN until a mutable URL serves the expected SHA-256, or the
* timeout elapses. **For mutable URLs only** — for immutable URLs (the
* `immutableUrl` returned by `put`) no waiting is needed; they're bound
* at upload time and never previously cached.
*
* Default `timeoutMs` is 60_000 (60 s). The helper polls the gateway's
* diagnose endpoint with exponential backoff bounded by 1 s; each poll
* may itself warm the cache for the probed PoP, so subsequent reads from
* other PoPs may still be stale until invalidation propagation completes.
*
* @example
* await client.blobs.waitFresh("prj_abc", {
* url: result.url, // the mutable URL from blobs.put
* sha256: result.contentSha256,
* timeoutMs: 30_000,
* });
*/
async waitFresh(projectId, opts) {
const timeoutMs = opts.timeoutMs ?? 60_000;
assertPositiveSafeInteger(timeoutMs, "timeoutMs", "waiting for CDN freshness");
const project = await requireProjectCredentials(this.client, projectId, "waiting for CDN freshness");
const expected = opts.sha256.toLowerCase();
const start = Date.now();
let attempts = 0;
let observed = null;
let delay = 100;
while (Date.now() - start < timeoutMs) {
attempts++;
try {
const envelope = await this.diagnoseUrl(projectId, opts.url);
observed = envelope.observedSha256;
if (observed && observed.toLowerCase() === expected) {
return {
fresh: true,
observedSha256: observed,
attempts,
elapsedMs: Date.now() - start,
vantage: "gateway-us-east-1",
};
}
}
catch {
// Swallow & retry — `diagnoseUrl` can fail on transient gateway
// hiccups (e.g. ALB cycling). The next poll re-attempts.
}
const remaining = timeoutMs - (Date.now() - start);
if (remaining <= 0)
break;
await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
delay = Math.min(delay * 2, 1000);
}
return {
fresh: false,
observedSha256: observed,
attempts,
elapsedMs: Date.now() - start,
vantage: "gateway-us-east-1",
};
}
/**
* Download a blob. Returns the raw `Response` so callers can stream to
* disk, pipe to another sink, or buffer with `.bytes()` / `.arrayBuffer()`.
* This avoids forcing large blobs through a JS buffer.
*
* @throws {ProjectNotFound} if `projectId` is not in the provider.
* @throws {ApiError} on non-2xx (includes the error text from the response body).
*/
async get(projectId, key) {
const project = await requireProjectCredentials(this.client, projectId, "downloading blob");
const url = `${this.client.apiBase}/storage/v1/blob/${encodeKey(key)}`;
const res = await this.client.fetch(url, {
headers: {
apikey: project.service_key,
Authorization: `Bearer ${project.service_key}`,
},
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new ApiError(`Downloading blob failed (HTTP ${res.status})`, res.status, errText, "downloading blob");
}
return res;
}
/** List blobs with optional prefix + pagination + (v1.50) sort + filter.
* Unknown filter keys, invalid sort values, and other structural issues
* are rejected client-side BEFORE any HTTP call. */
async ls(projectId, opts = {}) {
if (opts.limit !== undefined) {
assertPositiveSafeInteger(opts.limit, "limit", "listing blobs");
}
if (opts.sort !== undefined) {
assertAssetSortKey(opts.sort, "listing blobs");
}
if (opts.filter !== undefined) {
assertAssetFilter(opts.filter, "listing blobs");
}
const project = await requireProjectCredentials(this.client, projectId, "listing blobs");
const qs = new URLSearchParams();
if (opts.prefix)
qs.set("prefix", opts.prefix);
if (opts.limit !== undefined)
qs.set("limit", String(opts.limit));
if (opts.cursor)
qs.set("cursor", opts.cursor);
if (opts.sort)
qs.set("sort", opts.sort);
if (opts.filter)
appendAssetFilterTo(qs, opts.filter);
const query = qs.toString();
const path = `/storage/v1/blobs${query ? "?" + query : ""}`;
return this.client.request(path, {
headers: {
apikey: project.service_key,
Authorization: `Bearer ${project.service_key}`,
},
context: "listing blobs",
});
}
/** Delete a blob and decrement the project's storage_bytes. */
async rm(projectId, key) {
const project = await requireProjectCredentials(this.client, projectId, "deleting blob");
await this.client.request(`/storage/v1/blob/${encodeKey(key)}`, {
method: "DELETE",
headers: {
apikey: project.service_key,
Authorization: `Bearer ${project.service_key}`,
},
context: "deleting blob",
});
}
/** Generate a time-boxed S3 presigned GET URL for a blob. Default TTL 1 hour, max 7 days. */
async sign(projectId, key, opts = {}) {
if (opts.ttl_seconds !== undefined) {
assertIntegerInRange(opts.ttl_seconds, "ttl_seconds", 60, 604800, "signing blob URL");
}
const project = await requireProjectCredentials(this.client, projectId, "signing blob URL");
const body = {};
if (opts.ttl_seconds !== undefined)
body.ttl_seconds = opts.ttl_seconds;
return this.client.request(`/storage/v1/blob/${encodeKey(key)}/sign`, {
method: "POST",
headers: {
apikey: project.service_key,
Authorization: `Bearer ${project.service_key}`,
},
body,
context: "signing blob URL",
});
}
}
//# sourceMappingURL=assets.js.map