UNPKG

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.

367 lines • 16.8 kB
/** * Node-only `Assets` namespace enrichments (v1.48 unified-apply). * * `NodeAssets` extends the isomorphic {@link Assets} with directory-walking * helpers — `uploadDir` (additive), `syncDir` (declarative prune), and * `prepareDir` (pre-commit URL injection) — that read bytes from disk * lazily, compute SHA-256s in streaming chunks, and submit through the * single hero `r.project(id).apply(spec)` engine. See design D8/D10/D11. * * Imports `node:fs/promises` via `fileSetFromDir`, so this module is * Node-only — V8 isolates use `r.project(id).assets.putMany(items)` with * in-memory byte sources. */ import { Assets } from "../namespaces/assets.js"; import { Deploy } from "../namespaces/deploy.js"; import { LocalError } from "../errors.js"; import { fileSetFromDir } from "./files.js"; import { assertAssetMetadata, assertExifPolicy, } from "../namespaces/assets-validation.js"; /** * Build a `LocalDirRef` for the given filesystem path. Synchronous — the * actual filesystem walk happens at `apply()` submission time per design * D12 ("dir(path) returns synchronous LocalDirRef; SDK-input-only"). * * Accepted as `site.replace` and `site.patch.put` on `r.project(p).apply`. * For assets, use `r.project(p).assets.uploadDir(path)` / * `syncDir` / `prepareDir` — those wrap a `LocalDirRef` walk into the * unified-apply pipeline with directory-specific options (prune, prepare, * progress events). * * @example * import { dir, run402 } from "@run402/sdk/node"; * const r = run402(); * await r.project(p).apply({ * site: dir("./dist"), * }); * await r.project(p).assets.uploadDir("./assets", { prefix: "static/" }); */ export function dir(path, opts = {}) { return { __source: "local-dir", path, prefix: opts.prefix, ignore: opts.ignore, includeSensitive: opts.includeSensitive, }; } // ─────────────────────────────────────────────────────────────────────────── // Asset slice input building (SDK input → AssetPutEntryInput) // ─────────────────────────────────────────────────────────────────────────── /** * Walk a `LocalDirRef` and emit `AssetPutEntryInput[]` carrying a * `ContentSource` per file (rather than a pre-computed SHA). The actual * hashing + byte-reader registration happens inside the SDK's * `normalizeAssetSlice` pipeline so the bytes are uploaded via the shared * `byteReaders` map (v1.48 unified-apply). Returning wire-shaped entries * here would skip byte-reader registration and the deploy would fail with * `Missing bytes for sha=<...>` at upload time. * * The prefix is applied to relative keys. content_type/visibility/immutable * are left to the normalizer's defaults (visibility=public, immutable=true, * content_type derived from extension). */ export async function entriesFromLocalDir(ref) { const fileSetOpts = { ignore: ref.ignore, includeSensitive: ref.includeSensitive, }; const fileSet = await fileSetFromDir(ref.path, fileSetOpts); const entries = []; for (const [relPath, source] of Object.entries(fileSet)) { const key = applyPrefix(ref.prefix, relPath); if (typeof source === "object" && source !== null && "__source" in source) { const fs = source; if (fs.__source === "fs-file") { entries.push({ key, source: fs, content_type: fs.contentType, }); continue; } } entries.push({ key, source }); } return entries; } function applyPrefix(prefix, relPath) { if (!prefix) return relPath; return prefix.endsWith("/") ? `${prefix}${relPath}` : `${prefix}/${relPath}`; } /** * v1.50: apply per-call default metadata + EXIF policy to every entry walked * from a directory. Validates the defaults ONCE up front (fail fast) so a * malformed metadata bag or invalid exifPolicy throws before any byte-reader * is registered. Returns a new entries array with the per-call defaults * merged onto each entry that does not already carry its own value (entry- * level values always win). */ function applyAssetEntryDefaults(entries, defaults, context) { if (defaults.metadata !== undefined) { assertAssetMetadata(defaults.metadata, context); } if (defaults.exifPolicy !== undefined) { assertExifPolicy(defaults.exifPolicy, context); } if (defaults.metadata === undefined && defaults.exifPolicy === undefined) { return entries; } return entries.map((e) => ({ ...e, ...(e.metadata === undefined && defaults.metadata !== undefined ? { metadata: defaults.metadata } : {}), ...(e.exifPolicy === undefined && defaults.exifPolicy !== undefined ? { exifPolicy: defaults.exifPolicy } : {}), })); } /** * Build an `AssetManifest` from a `DeployResult`. The gateway plan * response is the authoritative source — `result.assets` is populated by * `buildAssetManifestFromPlanEntries` in deploy.ts from * `plan.asset_entries[].asset_ref`. Throws if the result has no `assets` * (which would mean the gateway didn't echo asset_entries — older * gateway pre-v1.48 or a release-only apply where the spec carried no * assets slice). */ function manifestFromResult(result, pruned, durationMs) { if (!result.assets) { throw new LocalError("Deploy result missing `assets` — gateway plan response did not include asset_entries. Requires gateway v1.48+.", "building asset manifest"); } const out = { list: result.assets.list, byKey: result.assets.byKey, manifest: result.assets.manifest, totals: { files: result.assets.totals?.files ?? result.assets.list.length, bytes_uploaded: result.assets.totals?.bytes_uploaded ?? 0, bytes_reused: result.assets.totals?.bytes_reused ?? 0, duration_ms: durationMs, }, }; if (pruned) out.pruned = pruned; return out; } /** * Error thrown by `syncDir({ prune: true })` when called without a * `confirm` token. Carries the values the caller must echo back to * commit the destructive operation. The SDK auto-throws this before the * destructive apply lands so the agent has a chance to surface the * planned delete set to the user. */ export class PruneConfirmationRequired extends LocalError { code = "PRUNE_CONFIRMATION_REQUIRED"; base_revision; delete_set_digest; expected_delete_count; sample_keys; constructor(args) { super(`Destructive syncDir requires a confirmation token. Re-call with confirm: { base_revision, delete_set_digest, expected_delete_count } from a prior plan.`, "preparing destructive asset sync"); this.base_revision = args.base_revision; this.delete_set_digest = args.delete_set_digest; this.expected_delete_count = args.expected_delete_count; this.sample_keys = args.sample_keys; } } export class NodeAssets extends Assets { /** * Additive directory upload. Existing keys under the (optional) prefix * that aren't in the new directory are left untouched. Per design * D10. Wraps `r._applyEngine.apply({ project, assets: { put: ... } })`. */ async uploadDir(path, opts) { const start = Date.now(); const ref = dir(path, { prefix: opts.prefix, ignore: opts.ignore, includeSensitive: opts.includeSensitive, }); const entries = await entriesFromLocalDir(ref); if (entries.length === 0) { throw new LocalError(`No files found under ${path} (after applying the ignore list)`, "uploading asset directory"); } // v1.50: apply per-call default metadata + EXIF policy to every entry // walked from the directory. The deploy normalizer validates each // entry's metadata/exifPolicy individually and would surface // INVALID_ASSET_METADATA / INVALID_EXIF_POLICY before any HTTP call, // but for a per-call default we want to fail fast (once) rather than // re-validate per entry. const decoratedEntries = applyAssetEntryDefaults(entries, { metadata: opts.metadata, exifPolicy: opts.exifPolicy, }, "uploading asset directory"); const result = await this.applyEngine().apply({ project_id: opts.project, assets: { put: decoratedEntries } }, { onEvent: opts.onEvent }); return manifestFromResult(result, undefined, Date.now() - start); } /** * Declarative directory sync. Per design D10: * - Without `prune: true`, behaves identically to {@link uploadDir} * (additive only). * - With `prune: true` AND no `confirm` token, runs a plan first and * throws {@link PruneConfirmationRequired} carrying the values the * caller must echo back to commit. * - With `prune: true` AND a `confirm` token, commits the destructive * sync. The gateway's activation-time drift check * (`ASSET_SYNC_DRIFT`) catches the narrower race where inventory * mutates between commit and activation. */ async syncDir(path, opts) { const start = Date.now(); const ref = dir(path, { prefix: opts.prefix, ignore: opts.ignore, includeSensitive: opts.includeSensitive, }); const entries = applyAssetEntryDefaults(await entriesFromLocalDir(ref), { metadata: opts.metadata, exifPolicy: opts.exifPolicy }, "syncing asset directory"); if (!opts.prune) { // Additive sync — same path as uploadDir. const result = await this.applyEngine().apply({ project_id: opts.project, assets: { put: entries } }, { onEvent: opts.onEvent }); return manifestFromResult(result, undefined, Date.now() - start); } if (!opts.prefix) { throw new LocalError("syncDir({ prune: true }) requires an explicit prefix (no implicit project-root prune)", "preparing destructive asset sync"); } if (!opts.confirm) { // Run a plan to obtain the gateway-computed base_revision + // delete_set_digest + sample of planned-delete keys (design D10). // The plan response carries an `asset_sync` block when the spec // declares destructive sync; we surface its values via the typed // PruneConfirmationRequired error so the caller can present them // to a user, then retry with `confirm: {...}` populated. const { plan } = await this.applyEngine().plan({ project_id: opts.project, assets: { put: entries, sync: { prefix: opts.prefix, prune: true }, }, }, { dryRun: true }); const block = plan.asset_sync; throw new PruneConfirmationRequired({ base_revision: block?.base_revision ?? "", delete_set_digest: block?.delete_set_digest ?? "", expected_delete_count: block?.expected_delete_count ?? 0, sample_keys: block?.sample_keys ?? [], }); } const result = await this.applyEngine().apply({ project_id: opts.project, assets: { put: entries, sync: { prefix: opts.prefix, prune: true, confirm: opts.confirm, }, }, }, { onEvent: opts.onEvent }); return manifestFromResult(result, // pruned[] is populated by the activation transaction (gateway- // side asset slice promotion). Without the enriched plan response // (follow-up), we don't yet have the materialized list here. undefined, Date.now() - start); } /** * Pre-commit URL injection helper (design D8 / spec Requirement * "assets.prepareDir"). Runs a plan against `r.project(id).apply.plan` * and returns `{ manifest, applySlice }` so the caller can render HTML * against the final resolved URLs and submit the same `applySlice` to * the commit step without re-uploading. Currently a thin shim — the * full plan-response enrichment lands in a follow-up. */ async prepareDir(path, opts) { const start = Date.now(); const ref = dir(path, { prefix: opts.prefix, ignore: opts.ignore, includeSensitive: opts.includeSensitive, }); const entries = await entriesFromLocalDir(ref); if (entries.length === 0) { throw new LocalError(`No files found under ${path} (after applying the ignore list)`, "preparing asset directory"); } // Plan-only via the engine — the gateway resolves URLs at plan time // (URLs are deterministic from `(project_public_id, key, // content_sha256)` per design D8). The caller renders HTML against // these resolved URLs, then calls `apply()` with the returned // `applySlice` to commit. We return the original input entries (with // sources retained) as the applySlice so the SDK normalizer can // register byte readers when the caller commits. const { plan } = await this.applyEngine().plan({ project_id: opts.project, assets: { put: entries } }, { dryRun: true }); const planEntries = plan.asset_entries ?? []; const list = []; const byKey = Object.create(null); const manifest = Object.create(null); for (const entry of planEntries) { const e = { key: entry.key, sha256: entry.sha256, size_bytes: entry.size_bytes, content_type: entry.content_type, visibility: entry.visibility, url: entry.asset_ref.url, immutable_url: entry.asset_ref.immutable_url, cdn_url: entry.asset_ref.cdn_url, cdn_immutable_url: entry.asset_ref.cdn_immutable_url, sri: entry.asset_ref.sri, etag: entry.asset_ref.etag, content_digest: entry.asset_ref.content_digest, }; list.push(e); byKey[entry.key] = e; manifest[entry.key] = e; } return { manifest: { list, byKey, manifest, totals: { files: planEntries.length, bytes_uploaded: 0, bytes_reused: 0, duration_ms: Date.now() - start, }, }, applySlice: { put: entries }, }; } /** * In-memory batch upload. Each item carries the key + an in-memory * `ContentSource` (string, Uint8Array, ArrayBuffer, Blob). SHA-256 is * computed locally, then a single apply call submits all entries. * Returns `AssetManifest`. */ async putMany(items, opts) { const start = Date.now(); if (items.length === 0) { throw new LocalError("putMany() requires at least one item", "uploading asset batch"); } const entries = items.map((item) => ({ key: item.key, source: item.source, content_type: item.contentType, visibility: item.visibility, immutable: item.immutable, // v1.50: per-item metadata + exifPolicy passthrough. The deploy // normalizer validates each value individually with the same // `INVALID_ASSET_METADATA` / `INVALID_EXIF_POLICY` codes the // gateway would return. ...(item.metadata !== undefined ? { metadata: item.metadata } : {}), ...(item.exifPolicy !== undefined ? { exifPolicy: item.exifPolicy } : {}), })); const result = await this.applyEngine().apply({ project_id: opts.project, assets: { put: entries } }, { onEvent: opts.onEvent }); return manifestFromResult(result, undefined, Date.now() - start); } /** * Internal: instantiate a Deploy (engine) bound to the same Client * this Assets instance was constructed with. Avoids requiring a * separate apply-engine parameter on the namespace constructor. */ applyEngine() { return new Deploy(this.client); } } //# sourceMappingURL=assets-node.js.map