diff --git a/README.md b/README.md index 1f88ea337..b3ae6d34d 100644 --- a/README.md +++ b/README.md @@ -813,11 +813,20 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and an existing destination is always replaced - `GET /api/blob?url=` - blob metadata (`head()`) -- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode) +- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) +- `POST /api/blob/mpu` with `x-mpu-action: create` - start a multipart upload (`createMultipartUpload()`), returning `{ key, uploadId }` - `GET /blob/:storeId/` - serve blob content (public, no auth; `?download=1` adds an attachment disposition) +Upload and deletion rules: + +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. + Point the SDK at the emulator with two environment variables: ```bash @@ -825,7 +834,7 @@ VERCEL_BLOB_API_URL=http://localhost:4000/api/blob BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret ``` -Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads and client (browser) uploads are not supported yet. +Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. ## GitHub API diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index ffe5be1d4..403d35886 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -61,11 +61,20 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and an existing destination is always replaced - `GET /api/blob?url=` - blob metadata (`head()`) -- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode) +- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) +- `POST /api/blob/mpu` with `x-mpu-action: create` - start a multipart upload (`createMultipartUpload()`), returning `{ key, uploadId }` - `GET /blob/:storeId/` - serve blob content (public, no auth; `?download=1` adds an attachment disposition) +Upload and deletion rules: + +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. + Point the SDK at the emulator with two environment variables: ```bash @@ -73,4 +82,4 @@ VERCEL_BLOB_API_URL=http://localhost:4000/api/blob BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret ``` -Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads and client (browser) uploads are not supported yet. +Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. diff --git a/packages/@emulators/vercel/src/__tests__/blob.test.ts b/packages/@emulators/vercel/src/__tests__/blob.test.ts index be5857218..aae301d54 100644 --- a/packages/@emulators/vercel/src/__tests__/blob.test.ts +++ b/packages/@emulators/vercel/src/__tests__/blob.test.ts @@ -365,7 +365,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await response.text()).toBe(""); }); - it.fails("rejects an empty deletion batch without changing stored content", async () => { + it("rejects an empty deletion batch without changing stored content", async () => { const response = await fetch(`${apiUrl}/delete`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, @@ -380,7 +380,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("rejects malformed deletion JSON without changing stored content", async () => { + it("rejects malformed deletion JSON without changing stored content", async () => { const response = await fetch(`${apiUrl}/delete`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, @@ -397,7 +397,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("copies to a new pathname when ifMatch matches the source ETag", async () => { + it("copies to a new pathname when ifMatch matches the source ETag", async () => { const response = await fetch( `${apiUrl}?${new URLSearchParams({ pathname: "http-copy/new.bin", fromUrl: copySource.url })}`, { method: "PUT", headers: { ...headers, "x-if-match": copySource.etag } }, @@ -408,7 +408,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await (await fetch(copied.url)).text()).toBe("source"); }); - it.fails("rejects conditional copy when only the destination ETag matches", async () => { + it("rejects conditional copy when only the destination ETag matches", async () => { const response = await fetch( `${apiUrl}?${new URLSearchParams({ pathname: copyDestination.pathname, fromUrl: copySource.url })}`, { method: "PUT", headers: { ...headers, "x-if-match": copyDestination.etag } }, @@ -429,7 +429,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("paginates folded listings that contain only folders", async () => { + it("paginates folded listings that contain only folders", async () => { const first = await list({ prefix: "http-folded/", mode: "folded", limit: 1, token }); expect(first.blobs).toEqual([]); expect(first.folders).toEqual(["http-folded/a/"]); @@ -441,7 +441,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(second.hasMore).toBe(false); }); - it.fails("uses the Content-Type header for an extensionless upload", async () => { + it("uses the Content-Type header for an extensionless upload", async () => { const response = await fetch(`${apiUrl}?pathname=http-metadata%2Fextensionless`, { method: "PUT", headers: { ...headers, "content-type": "application/x-test-fixture" }, @@ -461,7 +461,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("clamps modern cache max-age to at least one minute", async () => { + it("clamps modern cache max-age to at least one minute", async () => { const response = await fetch(`${apiUrl}?pathname=http-cache%2Fminimum.bin`, { method: "PUT", headers: { ...headers, "x-cache-control-max-age": "0" }, @@ -474,7 +474,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await (await fetch(uploaded.url)).text()).toBe("bytes"); }); - it.fails("removes a leading slash from the uploaded pathname", async () => { + it("removes a leading slash from the uploaded pathname", async () => { const response = await fetch(`${apiUrl}?pathname=%2Fhttp-paths%2Fleading.bin`, { method: "PUT", headers, @@ -489,7 +489,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("creates a multipart session without publishing a blob", async () => { + it("creates a multipart session without publishing a blob", async () => { const response = await fetch(`${apiUrl}/mpu?pathname=http-multipart%2Fsession.bin`, { method: "POST", headers: { ...headers, "x-mpu-action": "create" }, diff --git a/packages/@emulators/vercel/src/entities.ts b/packages/@emulators/vercel/src/entities.ts index 76a355350..29e14ade5 100644 --- a/packages/@emulators/vercel/src/entities.ts +++ b/packages/@emulators/vercel/src/entities.ts @@ -248,6 +248,26 @@ export interface VercelBlob extends Entity { dataBase64: string; } +export interface VercelBlobUploadPart { + partNumber: number; + etag: string; + size: number; + dataBase64: string; +} + +// An in-progress multipart upload. The blob is only published on completion, +// using the metadata captured when the upload was created. Overwrite and +// ifMatch checks come from the completion request instead. +export interface VercelBlobUpload extends Entity { + uploadId: string; + storeId: string; + pathname: string; + contentType: string; + cacheControl: string; + createdAt: string; + parts: VercelBlobUploadPart[]; +} + export interface VercelIntegration extends Entity { client_id: string; client_secret: string; diff --git a/packages/@emulators/vercel/src/routes/blob/constants.ts b/packages/@emulators/vercel/src/routes/blob/constants.ts index f7dbc99f6..825639d47 100644 --- a/packages/@emulators/vercel/src/routes/blob/constants.ts +++ b/packages/@emulators/vercel/src/routes/blob/constants.ts @@ -1,2 +1,4 @@ export const DEFAULT_CACHE_MAX_AGE = 2592000; +export const MIN_CACHE_MAX_AGE = 60; export const DEFAULT_LIST_LIMIT = 1000; +export const MAX_DELETE_URLS = 1000; diff --git a/packages/@emulators/vercel/src/routes/blob/delete.ts b/packages/@emulators/vercel/src/routes/blob/delete.ts index c288ca97a..fa4f849a7 100644 --- a/packages/@emulators/vercel/src/routes/blob/delete.ts +++ b/packages/@emulators/vercel/src/routes/blob/delete.ts @@ -1,8 +1,10 @@ import type { BlobRouteContext } from "./context.js"; +import { MAX_DELETE_URLS } from "./constants.js"; import { blobErr, forbidden } from "./errors.js"; import { resolveStoreId } from "./auth.js"; import { resolveBlobRef } from "./paths.js"; import { findBlobRef } from "./lookup.js"; +import { etagMatches } from "./etag.js"; export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): void { // Delete. @@ -11,23 +13,32 @@ export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): vo const storeId = resolveStoreId(c); if (!storeId) return forbidden(c); - let body: { urls?: unknown }; + let body: unknown; try { - body = (await c.req.json()) as { urls?: unknown }; + body = await c.req.json(); } catch { - body = {}; + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); + } + const urls = typeof body === "object" && body !== null ? (body as { urls?: unknown }).urls : undefined; + if (!urls || (Array.isArray(urls) && urls.length === 0)) { + return blobErr(c, 400, "bad_request", "Missing urls in request body"); + } + if (!Array.isArray(urls)) { + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); + } + if (urls.length > MAX_DELETE_URLS) { + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once"); + } + if (!urls.every((u): u is string => typeof u === "string")) { + return blobErr(c, 400, "bad_request", "Some urls are malformed"); } - const urls = Array.isArray(body.urls) ? body.urls.filter((u): u is string => typeof u === "string") : []; const ifMatch = c.req.header("x-if-match"); for (const urlOrPathname of urls) { const blob = findBlobRef(vs, storeId, resolveBlobRef(urlOrPathname, parsedBaseUrl)); if (!blob) continue; - if (ifMatch) { - const normalized = ifMatch.startsWith('"') ? ifMatch : `"${ifMatch}"`; - if (blob.etag !== normalized) { - return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); - } + if (ifMatch && !etagMatches(blob.etag, ifMatch)) { + return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); } vs.blobs.delete(blob.id); } diff --git a/packages/@emulators/vercel/src/routes/blob/etag.ts b/packages/@emulators/vercel/src/routes/blob/etag.ts new file mode 100644 index 000000000..1cf20cf96 --- /dev/null +++ b/packages/@emulators/vercel/src/routes/blob/etag.ts @@ -0,0 +1,8 @@ +// Clients may send x-if-match with or without the surrounding quotes. +export function normalizeEtag(etag: string): string { + return etag.startsWith('"') ? etag : `"${etag}"`; +} + +export function etagMatches(etag: string | undefined, ifMatch: string): boolean { + return etag !== undefined && etag === normalizeEtag(ifMatch); +} diff --git a/packages/@emulators/vercel/src/routes/blob/head-list.ts b/packages/@emulators/vercel/src/routes/blob/head-list.ts index 9ac73b82e..ac2474dbd 100644 --- a/packages/@emulators/vercel/src/routes/blob/head-list.ts +++ b/packages/@emulators/vercel/src/routes/blob/head-list.ts @@ -1,4 +1,5 @@ import type { Context } from "@emulators/core"; +import type { VercelBlob } from "../../entities.js"; import type { BlobRouteContext } from "./context.js"; import { DEFAULT_LIST_LIMIT } from "./constants.js"; import { blobErr, forbidden } from "./errors.js"; @@ -30,28 +31,27 @@ export function registerHeadList({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteC const cursor = c.req.query("cursor"); const folded = c.req.query("mode") === "folded"; - let items = vs.blobs - .findBy("storeId", storeId) - .filter((b) => b.pathname.startsWith(prefix)) - .sort((a, b) => (a.pathname < b.pathname ? -1 : a.pathname > b.pathname ? 1 : 0)); - - const folders = new Set(); - if (folded) { - items = items.filter((b) => { - const rest = b.pathname.slice(prefix.length); - const slash = rest.indexOf("/"); - if (slash === -1) return true; - folders.add(prefix + rest.slice(0, slash + 1)); - return false; - }); + // Folded listings paginate over folders and root blobs together, so each + // entry is keyed by its folder prefix or its pathname. + const entries = new Map(); + for (const b of vs.blobs.findBy("storeId", storeId)) { + if (!b.pathname.startsWith(prefix)) continue; + const slash = folded ? b.pathname.indexOf("/", prefix.length) : -1; + if (slash === -1) { + entries.set(b.pathname, b); + } else { + entries.set(b.pathname.slice(0, slash + 1), null); + } } + let keys = [...entries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); if (cursor) { - items = items.filter((b) => b.pathname > cursor); + keys = keys.filter((key) => key > cursor); } - const hasMore = items.length > limit; - const page = items.slice(0, limit); + const hasMore = keys.length > limit; + const pageKeys = keys.slice(0, limit); + const page = pageKeys.flatMap((key) => entries.get(key) ?? []); const result: Record = { blobs: page.map((b) => { @@ -67,11 +67,11 @@ export function registerHeadList({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteC }), hasMore, }; - if (hasMore && page.length > 0) { - result.cursor = page[page.length - 1].pathname; + if (hasMore && pageKeys.length > 0) { + result.cursor = pageKeys[pageKeys.length - 1]; } if (folded) { - result.folders = [...folders].sort(); + result.folders = pageKeys.filter((key) => entries.get(key) === null); } return c.json(result); }; diff --git a/packages/@emulators/vercel/src/routes/blob/multipart.ts b/packages/@emulators/vercel/src/routes/blob/multipart.ts index 268bb91ab..5c0b39ff0 100644 --- a/packages/@emulators/vercel/src/routes/blob/multipart.ts +++ b/packages/@emulators/vercel/src/routes/blob/multipart.ts @@ -1,12 +1,37 @@ +import { randomBytes } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; import { blobErr } from "./errors.js"; +import { cacheControlFor, contentTypeFor, resolveUploadTarget } from "./upload.js"; -export function registerMultipart({ app }: BlobRouteContext): void { - // Multipart uploads are not supported yet. +export function registerMultipart({ app, vs }: BlobRouteContext): void { + // Multipart uploads. Creation records a session; uploading parts and + // completing the upload are not supported yet. - const handleMpu = (c: Context): Response => - blobErr(c, 400, "bad_request", "Multipart uploads are not supported by the emulator yet"); + const handleCreate = (c: Context): Response => { + const target = resolveUploadTarget(c); + if (target instanceof Response) return target; + const { storeId, pathname } = target; + + const uploadId = randomBytes(16).toString("hex"); + vs.blobUploads.insert({ + uploadId, + storeId, + pathname, + contentType: contentTypeFor(c, pathname), + cacheControl: cacheControlFor(c.req.header("x-cache-control-max-age")), + createdAt: new Date().toISOString(), + parts: [], + }); + return c.json({ key: pathname, uploadId }); + }; + + const handleMpu = (c: Context): Response => { + if (c.req.method === "POST" && c.req.header("x-mpu-action") === "create") { + return handleCreate(c); + } + return blobErr(c, 400, "bad_request", "Multipart uploads are not supported by the emulator yet"); + }; app.post("/api/blob/mpu", handleMpu); app.put("/api/blob/mpu", handleMpu); } diff --git a/packages/@emulators/vercel/src/routes/blob/paths.ts b/packages/@emulators/vercel/src/routes/blob/paths.ts index daac00052..a6db579d9 100644 --- a/packages/@emulators/vercel/src/routes/blob/paths.ts +++ b/packages/@emulators/vercel/src/routes/blob/paths.ts @@ -28,12 +28,16 @@ export const MIME_TYPES: Record = { zip: "application/zip", }; -export function inferContentType(pathname: string): string { +export function knownContentType(pathname: string): string | undefined { const basename = pathname.split("/").pop() ?? ""; const dot = basename.lastIndexOf("."); - if (dot <= 0) return "application/octet-stream"; + if (dot <= 0) return undefined; const ext = basename.slice(dot + 1).toLowerCase(); - return MIME_TYPES[ext] ?? "application/octet-stream"; + return MIME_TYPES[ext]; +} + +export function inferContentType(pathname: string): string { + return knownContentType(pathname) ?? "application/octet-stream"; } export function randomSuffix(): string { diff --git a/packages/@emulators/vercel/src/routes/blob/put.ts b/packages/@emulators/vercel/src/routes/blob/put.ts index 882290b3a..bcdff9aed 100644 --- a/packages/@emulators/vercel/src/routes/blob/put.ts +++ b/packages/@emulators/vercel/src/routes/blob/put.ts @@ -1,64 +1,43 @@ import { createHash } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; -import { DEFAULT_CACHE_MAX_AGE } from "./constants.js"; -import { blobErr, forbidden } from "./errors.js"; -import { resolveStoreId } from "./auth.js"; -import { - blobUrl, - contentDispositionFor, - downloadUrl, - inferContentType, - resolveBlobRef, - withRandomSuffix, -} from "./paths.js"; +import { blobErr } from "./errors.js"; +import { blobUrl, contentDispositionFor, downloadUrl, resolveBlobRef } from "./paths.js"; import { findBlob, findBlobRef } from "./lookup.js"; +import { etagMatches } from "./etag.js"; +import { cacheControlFor, contentTypeFor, resolveUploadTarget } from "./upload.js"; export function registerPut({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteContext): void { // Upload. The SDK sends PUT /?pathname= with the raw bytes as body. + // Copy sends the same request with ?fromUrl= and no body. const handlePut = async (c: Context): Promise => { - const storeId = resolveStoreId(c); - if (!storeId) return forbidden(c); + const target = resolveUploadTarget(c); + if (target instanceof Response) return target; + const { storeId, pathname } = target; - const rawPathname = c.req.query("pathname"); - if (!rawPathname) { - return blobErr(c, 400, "bad_request", "pathname is required"); - } - if (rawPathname.includes("//")) { - return blobErr(c, 400, "bad_request", "pathname cannot contain //"); - } - - const access = c.req.header("x-vercel-blob-access") ?? "public"; - if (access !== "public") { - return blobErr(c, 400, "bad_request", "Only access: public is supported by the emulator"); + const ifMatch = c.req.header("x-if-match") || undefined; + const fromUrl = c.req.query("fromUrl"); + const source = fromUrl === undefined ? undefined : findBlobRef(vs, storeId, resolveBlobRef(fromUrl, parsedBaseUrl)); + if (fromUrl !== undefined && !source) { + return blobErr(c, 404, "not_found", "The requested blob does not exist"); } - const pathname = c.req.header("x-add-random-suffix") === "1" ? withRandomSuffix(rawPathname) : rawPathname; - const existing = findBlob(vs, storeId, pathname); - const ifMatch = c.req.header("x-if-match"); - if (ifMatch) { - const normalized = ifMatch.startsWith('"') ? ifMatch : `"${ifMatch}"`; - if (!existing || existing.etag !== normalized) { - return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); - } - } else if (existing && c.req.header("x-allow-overwrite") !== "1") { - return blobErr(c, 400, "bad_request", "This blob already exists, use allowOverwrite: true to overwrite it"); + // A copy's ifMatch guards the source; a plain upload's ifMatch guards the destination. + if (ifMatch && !etagMatches(source ? source.etag : existing?.etag, ifMatch)) { + return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); } - - const fromUrl = c.req.query("fromUrl"); - const source = fromUrl === undefined ? undefined : findBlobRef(vs, storeId, resolveBlobRef(fromUrl, parsedBaseUrl)); - if (fromUrl !== undefined && !source) { - return blobErr(c, 404, "not_found", "The requested blob does not exist"); + // Copies always replace the destination, like the real service. + const overwriteAllowed = source !== undefined || ifMatch !== undefined || c.req.header("x-allow-overwrite") === "1"; + if (existing && !overwriteAllowed) { + return blobErr(c, 400, "bad_request", "This blob already exists, use allowOverwrite: true to overwrite it"); } const body = source ? Buffer.from(source.dataBase64, "base64") : Buffer.from(await c.req.arrayBuffer()); - const contentType = c.req.header("x-content-type") || inferContentType(pathname); - const maxAgeHeader = c.req.header("x-cache-control-max-age"); - const maxAge = maxAgeHeader ? parseInt(maxAgeHeader, 10) : NaN; - const cacheControl = `public, max-age=${Number.isFinite(maxAge) ? maxAge : DEFAULT_CACHE_MAX_AGE}`; + const contentType = contentTypeFor(c, pathname, { useRequestHeader: !source }); + const cacheControl = cacheControlFor(c.req.header("x-cache-control-max-age")); const contentDisposition = contentDispositionFor(pathname); const etag = `"${createHash("sha256").update(body).digest("hex")}"`; const uploadedAt = new Date().toISOString(); diff --git a/packages/@emulators/vercel/src/routes/blob/upload.ts b/packages/@emulators/vercel/src/routes/blob/upload.ts new file mode 100644 index 000000000..49cbaebac --- /dev/null +++ b/packages/@emulators/vercel/src/routes/blob/upload.ts @@ -0,0 +1,46 @@ +import type { Context } from "@emulators/core"; +import { DEFAULT_CACHE_MAX_AGE, MIN_CACHE_MAX_AGE } from "./constants.js"; +import { blobErr, forbidden } from "./errors.js"; +import { resolveStoreId } from "./auth.js"; +import { knownContentType, withRandomSuffix } from "./paths.js"; + +export interface UploadTarget { + storeId: string; + pathname: string; +} + +// Shared by single PUT uploads, copies and multipart creation: authenticates the +// request and turns the pathname query into the final stored pathname. +export function resolveUploadTarget(c: Context): UploadTarget | Response { + const storeId = resolveStoreId(c); + if (!storeId) return forbidden(c); + + // Like the real service, strip exactly one leading slash and accept "//" + // elsewhere; only the SDK rejects "//" before sending the request. + const rawPathname = c.req.query("pathname")?.replace(/^\//, ""); + if (!rawPathname) { + return blobErr(c, 400, "bad_request", "pathname is required"); + } + + const access = c.req.header("x-vercel-blob-access") ?? "public"; + if (access !== "public") { + return blobErr(c, 400, "bad_request", "Only access: public is supported by the emulator"); + } + + const pathname = c.req.header("x-add-random-suffix") === "1" ? withRandomSuffix(rawPathname) : rawPathname; + return { storeId, pathname }; +} + +export function contentTypeFor(c: Context, pathname: string, { useRequestHeader = true } = {}): string { + return ( + c.req.header("x-content-type") || + knownContentType(pathname) || + (useRequestHeader ? c.req.header("content-type") : undefined) || + "application/octet-stream" + ); +} + +export function cacheControlFor(maxAgeHeader: string | undefined): string { + const maxAge = maxAgeHeader ? parseInt(maxAgeHeader, 10) : NaN; + return `public, max-age=${Number.isFinite(maxAge) ? Math.max(maxAge, MIN_CACHE_MAX_AGE) : DEFAULT_CACHE_MAX_AGE}`; +} diff --git a/packages/@emulators/vercel/src/store.ts b/packages/@emulators/vercel/src/store.ts index 8f487eae7..8aef70db5 100644 --- a/packages/@emulators/vercel/src/store.ts +++ b/packages/@emulators/vercel/src/store.ts @@ -16,6 +16,7 @@ import type { VercelIntegration, VercelApiKey, VercelBlob, + VercelBlobUpload, } from "./entities.js"; export interface VercelStore { @@ -35,6 +36,7 @@ export interface VercelStore { apiKeys: Collection; integrations: Collection; blobs: Collection; + blobUploads: Collection; } export function getVercelStore(store: Store): VercelStore { @@ -58,5 +60,6 @@ export function getVercelStore(store: Store): VercelStore { apiKeys: store.collection("vercel.api_keys", ["uid", "teamId", "userId"]), integrations: store.collection("vercel.integrations", ["client_id"]), blobs: store.collection("vercel.blobs", ["pathname", "storeId"]), + blobUploads: store.collection("vercel.blob_uploads", ["uploadId", "storeId"]), }; } diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 9e2925d23..f0ced5b5f 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -381,11 +381,17 @@ curl -X PUT "http://localhost:4000/api/blob?pathname=docs/readme.txt" \ -H "Authorization: Bearer $BLOB_TOKEN" \ --data-binary @readme.txt +# Copy (x-if-match is compared with the source blob's ETag; +# an existing destination is always replaced) +curl -X PUT "http://localhost:4000/api/blob?pathname=docs/copy.txt&fromUrl=docs/readme.txt" \ + -H "Authorization: Bearer $BLOB_TOKEN" + # Metadata (head) curl "http://localhost:4000/api/blob?url=docs/readme.txt" \ -H "Authorization: Bearer $BLOB_TOKEN" -# List (prefix, limit, cursor, mode=folded) +# List (prefix, limit, cursor, mode=folded; folded pages share one +# sorted cursor across folders and root blobs) curl "http://localhost:4000/api/blob?prefix=docs/" \ -H "Authorization: Bearer $BLOB_TOKEN" @@ -395,11 +401,23 @@ curl -X POST http://localhost:4000/api/blob/delete \ -H "Content-Type: application/json" \ -d '{"urls": ["docs/readme.txt"]}' +# Start a multipart upload (returns { key, uploadId }) +curl -X POST "http://localhost:4000/api/blob/mpu?pathname=docs/large.bin" \ + -H "Authorization: Bearer $BLOB_TOKEN" \ + -H "x-mpu-action: create" + # Serve content (public, no auth; ?download=1 forces attachment) curl http://localhost:4000/blob/mystore/docs/readme.txt ``` -Multipart uploads and client (browser) uploads are not supported yet. +Upload and deletion rules: + +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. + +Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. ### API Keys