Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,19 +813,28 @@ 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=<path>` - 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=<path>&fromUrl=<url>` - 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=<urlOrPathname>` - 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/<pathname>` - 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
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_<storeId>_<secret>` 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_<storeId>_<secret>` 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

Expand Down
13 changes: 11 additions & 2 deletions apps/web/content/docs/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,25 @@ 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=<path>` - 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=<path>&fromUrl=<url>` - 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=<urlOrPathname>` - 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/<pathname>` - 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
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_<storeId>_<secret>` 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_<storeId>_<secret>` 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.
18 changes: 9 additions & 9 deletions packages/@emulators/vercel/src/__tests__/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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" },
Expand All @@ -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 } },
Expand All @@ -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 } },
Expand All @@ -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/"]);
Expand All @@ -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" },
Expand All @@ -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" },
Expand All @@ -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,
Expand All @@ -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" },
Expand Down
20 changes: 20 additions & 0 deletions packages/@emulators/vercel/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/@emulators/vercel/src/routes/blob/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 20 additions & 9 deletions packages/@emulators/vercel/src/routes/blob/delete.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions packages/@emulators/vercel/src/routes/blob/etag.ts
Original file line number Diff line number Diff line change
@@ -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);
}
40 changes: 20 additions & 20 deletions packages/@emulators/vercel/src/routes/blob/head-list.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>();
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<string, VercelBlob | null>();
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<string, unknown> = {
blobs: page.map((b) => {
Expand All @@ -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);
};
Expand Down
33 changes: 29 additions & 4 deletions packages/@emulators/vercel/src/routes/blob/multipart.ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 7 additions & 3 deletions packages/@emulators/vercel/src/routes/blob/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ export const MIME_TYPES: Record<string, string> = {
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 {
Expand Down
Loading
Loading