Skip to content

Commit 787041a

Browse files
authored
Add Vercel Blob emulator support (#175)
* Add Vercel Blob emulator support - Add stateful Vercel Blob emulation for put, head, list, delete, and public object serving - Cover Blob SDK behavior with @vercel/blob tests and register blob storage in service metadata - Document Blob configuration and make seeded Vercel user IDs stable * Fix Vercel Blob OIDC store resolution * Fix Vercel Blob copy and store scoping
1 parent 4045e54 commit 787041a

12 files changed

Lines changed: 789 additions & 6 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,24 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs
520520
- `PATCH /v9/projects/:idOrName/env/:id` - update env var
521521
- `DELETE /v9/projects/:idOrName/env/:id` - delete env var
522522

523+
### Blob
524+
Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`).
525+
526+
- `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)
527+
- `GET /api/blob?url=<urlOrPathname>` - blob metadata (`head()`)
528+
- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode)
529+
- `POST /api/blob/delete` - delete blobs (`del()`)
530+
- `GET /blob/:storeId/<pathname>` - serve blob content (public, no auth; `?download=1` adds an attachment disposition)
531+
532+
Point the SDK at the emulator with two environment variables:
533+
534+
```bash
535+
VERCEL_BLOB_API_URL=http://localhost:4000/api/blob
536+
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret
537+
```
538+
539+
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.
540+
523541
## GitHub API
524542

525543
Every endpoint below is fully stateful. Creates, updates, and deletes persist in memory and affect related entities.

apps/web/app/docs/vercel/page.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,22 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs
5151
- `GET /v10/projects/:idOrName/env/:id` - get env var
5252
- `PATCH /v9/projects/:idOrName/env/:id` - update env var
5353
- `DELETE /v9/projects/:idOrName/env/:id` - delete env var
54+
55+
## Blob
56+
57+
Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`).
58+
59+
- `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)
60+
- `GET /api/blob?url=<urlOrPathname>` - blob metadata (`head()`)
61+
- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode)
62+
- `POST /api/blob/delete` - delete blobs (`del()`)
63+
- `GET /blob/:storeId/<pathname>` - serve blob content (public, no auth; `?download=1` adds an attachment disposition)
64+
65+
Point the SDK at the emulator with two environment variables:
66+
67+
```bash
68+
VERCEL_BLOB_API_URL=http://localhost:4000/api/blob
69+
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret
70+
```
71+
72+
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.

packages/@emulators/vercel/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@emulators/core": "workspace:*"
3939
},
4040
"devDependencies": {
41+
"@vercel/blob": "^2.4.0",
4142
"tsup": "^8",
4243
"typescript": "^5.7",
4344
"vitest": "^4.1.0"
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { randomBytes } from "node:crypto";
3+
import type { AddressInfo } from "node:net";
4+
import { Hono, serve } from "@emulators/core";
5+
import { Store, WebhookDispatcher, authMiddleware, type TokenMap } from "@emulators/core";
6+
import { put, head, list, del, copy, BlobNotFoundError, BlobAccessError } from "@vercel/blob";
7+
import { vercelPlugin } from "../index.js";
8+
9+
const token = "vercel_blob_rw_teststore_secret";
10+
const storeId = "teststore";
11+
12+
let emulatorUrl: string;
13+
let apiUrl: string;
14+
let closeServer: () => Promise<void>;
15+
16+
beforeAll(async () => {
17+
const store = new Store();
18+
const webhooks = new WebhookDispatcher();
19+
const tokenMap: TokenMap = new Map();
20+
const app = new Hono();
21+
app.use("*", authMiddleware(tokenMap));
22+
23+
const server = serve({ fetch: app.fetch, port: 0 });
24+
await new Promise<void>((resolve, reject) => {
25+
server.once("listening", () => resolve());
26+
server.once("error", reject);
27+
});
28+
const { port } = server.address() as AddressInfo;
29+
emulatorUrl = `http://127.0.0.1:${port}`;
30+
apiUrl = `${emulatorUrl}/api/blob`;
31+
32+
// Blob URLs embed the base URL, so register routes after the port is known.
33+
vercelPlugin.register(app as any, store, webhooks, emulatorUrl, tokenMap);
34+
vercelPlugin.seed?.(store, emulatorUrl);
35+
36+
process.env.VERCEL_BLOB_API_URL = apiUrl;
37+
process.env.BLOB_READ_WRITE_TOKEN = token;
38+
39+
closeServer = () =>
40+
new Promise<void>((resolve, reject) => {
41+
server.close((err) => (err ? reject(err) : resolve()));
42+
});
43+
});
44+
45+
afterAll(async () => {
46+
delete process.env.VERCEL_BLOB_API_URL;
47+
delete process.env.BLOB_READ_WRITE_TOKEN;
48+
await closeServer();
49+
});
50+
51+
describe("Vercel Blob via the @vercel/blob SDK", () => {
52+
it("put uploads bytes and the returned url serves them back", async () => {
53+
const data = randomBytes(1024);
54+
const result = await put("round-trip/data.bin", data, { access: "public", token });
55+
56+
expect(result.pathname).toBe("round-trip/data.bin");
57+
expect(result.url).toBe(`${emulatorUrl}/blob/${storeId}/round-trip/data.bin`);
58+
expect(result.contentType).toBe("application/octet-stream");
59+
expect(result.contentDisposition).toBe('attachment; filename="data.bin"');
60+
61+
const res = await fetch(result.url);
62+
expect(res.status).toBe(200);
63+
const body = Buffer.from(await res.arrayBuffer());
64+
expect(body.equals(data)).toBe(true);
65+
expect(res.headers.get("etag")).toBe(result.etag);
66+
expect(res.headers.get("cache-control")).toBe("public, max-age=2592000");
67+
});
68+
69+
it("put with addRandomSuffix appends a suffix before the extension", async () => {
70+
const result = await put("suffix/report.txt", "hello suffix", {
71+
access: "public",
72+
token,
73+
addRandomSuffix: true,
74+
});
75+
76+
expect(result.pathname).toMatch(/^suffix\/report-[a-z0-9]+\.txt$/);
77+
expect(result.pathname).not.toBe("suffix/report.txt");
78+
79+
const res = await fetch(result.url);
80+
expect(res.status).toBe(200);
81+
expect(await res.text()).toBe("hello suffix");
82+
expect(res.headers.get("content-type")).toContain("text/plain");
83+
});
84+
85+
it("put with allowOverwrite: false rejects when the blob already exists", async () => {
86+
await put("conflict/file.txt", "first", { access: "public", token });
87+
await expect(
88+
put("conflict/file.txt", "second", { access: "public", token, allowOverwrite: false }),
89+
).rejects.toThrow(/allowOverwrite/);
90+
91+
// The original content is untouched and allowOverwrite: true replaces it.
92+
const before = await fetch(`${emulatorUrl}/blob/${storeId}/conflict/file.txt`);
93+
expect(await before.text()).toBe("first");
94+
95+
await put("conflict/file.txt", "second", { access: "public", token, allowOverwrite: true });
96+
const after = await fetch(`${emulatorUrl}/blob/${storeId}/conflict/file.txt`);
97+
expect(await after.text()).toBe("second");
98+
});
99+
100+
it("head returns blob metadata", async () => {
101+
const uploaded = await put("meta/info.json", JSON.stringify({ ok: true }), {
102+
access: "public",
103+
token,
104+
cacheControlMaxAge: 60,
105+
});
106+
107+
const meta = await head(uploaded.url, { token });
108+
expect(meta.pathname).toBe("meta/info.json");
109+
expect(meta.url).toBe(uploaded.url);
110+
expect(meta.downloadUrl).toBe(uploaded.downloadUrl);
111+
expect(meta.size).toBe(Buffer.byteLength(JSON.stringify({ ok: true })));
112+
expect(meta.contentType).toBe("application/json");
113+
expect(meta.contentDisposition).toBe('attachment; filename="info.json"');
114+
expect(meta.cacheControl).toBe("public, max-age=60");
115+
expect(meta.etag).toBe(uploaded.etag);
116+
expect(meta.uploadedAt).toBeInstanceOf(Date);
117+
expect(Number.isNaN(meta.uploadedAt.getTime())).toBe(false);
118+
});
119+
120+
it("head falls back to BLOB_READ_WRITE_TOKEN when no token option is given", async () => {
121+
const uploaded = await put("meta/env-token.txt", "env token", { access: "public", token });
122+
const meta = await head(uploaded.url);
123+
expect(meta.pathname).toBe("meta/env-token.txt");
124+
});
125+
126+
it("put accepts SDK OIDC auth with an explicit storeId", async () => {
127+
const uploaded = await put("auth/oidc.txt", "oidc token", {
128+
access: "public",
129+
oidcToken: "local-oidc-token",
130+
storeId,
131+
});
132+
133+
expect(uploaded.url).toBe(`${emulatorUrl}/blob/${storeId}/auth/oidc.txt`);
134+
const res = await fetch(uploaded.url);
135+
expect(res.status).toBe(200);
136+
expect(await res.text()).toBe("oidc token");
137+
});
138+
139+
it("copy duplicates source content into the destination blob", async () => {
140+
const source = await put("copy/source.txt", "copy me", { access: "public", token });
141+
const copied = await copy(source.url, "copy/dest.txt", { access: "public", token });
142+
143+
expect(copied.pathname).toBe("copy/dest.txt");
144+
const res = await fetch(copied.url);
145+
expect(res.status).toBe(200);
146+
expect(await res.text()).toBe("copy me");
147+
148+
const meta = await head(copied.url, { token });
149+
expect(meta.size).toBe("copy me".length);
150+
expect(meta.etag).toBe(source.etag);
151+
});
152+
153+
it("list filters by prefix and returns metadata", async () => {
154+
await put("listing/a.txt", "a", { access: "public", token });
155+
await put("listing/b.txt", "b", { access: "public", token });
156+
await put("other/c.txt", "c", { access: "public", token });
157+
158+
const result = await list({ prefix: "listing/", token });
159+
const pathnames = result.blobs.map((b) => b.pathname);
160+
expect(pathnames).toEqual(["listing/a.txt", "listing/b.txt"]);
161+
expect(result.hasMore).toBe(false);
162+
for (const blob of result.blobs) {
163+
expect(blob.url).toBe(`${emulatorUrl}/blob/${storeId}/${blob.pathname}`);
164+
expect(blob.size).toBe(1);
165+
expect(blob.uploadedAt).toBeInstanceOf(Date);
166+
expect(blob.etag).toMatch(/^"[0-9a-f]{64}"$/);
167+
}
168+
});
169+
170+
it("list paginates with limit and cursor", async () => {
171+
await put("paging/1.txt", "1", { access: "public", token });
172+
await put("paging/2.txt", "2", { access: "public", token });
173+
await put("paging/3.txt", "3", { access: "public", token });
174+
175+
const first = await list({ prefix: "paging/", limit: 2, token });
176+
expect(first.blobs.map((b) => b.pathname)).toEqual(["paging/1.txt", "paging/2.txt"]);
177+
expect(first.hasMore).toBe(true);
178+
expect(first.cursor).toBeDefined();
179+
180+
const second = await list({ prefix: "paging/", cursor: first.cursor, token });
181+
expect(second.blobs.map((b) => b.pathname)).toEqual(["paging/3.txt"]);
182+
expect(second.hasMore).toBe(false);
183+
});
184+
185+
it("del removes a blob and head then rejects with BlobNotFoundError", async () => {
186+
const uploaded = await put("deletion/gone.txt", "bye", { access: "public", token });
187+
await del(uploaded.url, { token });
188+
189+
await expect(head(uploaded.url, { token })).rejects.toBeInstanceOf(BlobNotFoundError);
190+
const res = await fetch(uploaded.url);
191+
expect(res.status).toBe(404);
192+
});
193+
194+
it("full blob URLs cannot target a different authenticated store", async () => {
195+
const otherToken = "vercel_blob_rw_otherstore_secret";
196+
const own = await put("store-scope/shared.txt", "own", { access: "public", token });
197+
const other = await put("store-scope/shared.txt", "other", { access: "public", token: otherToken });
198+
199+
await expect(head(other.url, { token })).rejects.toBeInstanceOf(BlobNotFoundError);
200+
201+
await del(other.url, { token });
202+
const ownRes = await fetch(own.url);
203+
const otherRes = await fetch(other.url);
204+
expect(ownRes.status).toBe(200);
205+
expect(await ownRes.text()).toBe("own");
206+
expect(otherRes.status).toBe(200);
207+
expect(await otherRes.text()).toBe("other");
208+
});
209+
210+
it("downloadUrl serves the content with an attachment disposition", async () => {
211+
const uploaded = await put("downloads/manual.txt", "download me", { access: "public", token });
212+
213+
const res = await fetch(uploaded.downloadUrl);
214+
expect(res.status).toBe(200);
215+
expect(res.headers.get("content-disposition")).toBe('attachment; filename="manual.txt"');
216+
expect(await res.text()).toBe("download me");
217+
218+
// The plain url has no attachment disposition.
219+
const inline = await fetch(uploaded.url);
220+
expect(inline.headers.get("content-disposition")).toBeNull();
221+
});
222+
223+
it("a bad token surfaces as BlobAccessError", async () => {
224+
await expect(list({ token: "not-a-blob-token" })).rejects.toBeInstanceOf(BlobAccessError);
225+
});
226+
});
227+
228+
describe("Vercel Blob direct HTTP error shapes", () => {
229+
it("returns a 403 forbidden JSON body for a bad token", async () => {
230+
const res = await fetch(apiUrl, { headers: { authorization: "Bearer wrong" } });
231+
expect(res.status).toBe(403);
232+
const body = (await res.json()) as { error: { code: string; message: string } };
233+
expect(body.error.code).toBe("forbidden");
234+
expect(typeof body.error.message).toBe("string");
235+
});
236+
237+
it("returns a 404 not_found JSON body when head misses", async () => {
238+
const res = await fetch(`${apiUrl}?url=${encodeURIComponent("missing/never-uploaded.txt")}`, {
239+
headers: { authorization: `Bearer ${token}` },
240+
});
241+
expect(res.status).toBe(404);
242+
const body = (await res.json()) as { error: { code: string; message: string } };
243+
expect(body.error.code).toBe("not_found");
244+
expect(body.error.message).toBe("The requested blob does not exist");
245+
});
246+
});

packages/@emulators/vercel/src/entities.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,18 @@ export interface VercelApiKey extends Entity {
236236
tokenString: string;
237237
}
238238

239+
export interface VercelBlob extends Entity {
240+
pathname: string;
241+
storeId: string;
242+
contentType: string;
243+
contentDisposition: string;
244+
cacheControl: string;
245+
size: number;
246+
etag: string;
247+
uploadedAt: string;
248+
dataBase64: string;
249+
}
250+
239251
export interface VercelIntegration extends Entity {
240252
client_id: string;
241253
client_secret: string;

packages/@emulators/vercel/src/helpers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { randomBytes } from "crypto";
1+
import { createHash, randomBytes } from "crypto";
22
import type { Context } from "@emulators/core";
33
import type {
44
VercelUser,
@@ -17,6 +17,17 @@ export function generateUid(prefix = ""): string {
1717
return prefix ? `${prefix}_${id}` : id;
1818
}
1919

20+
/**
21+
* Deterministic uid for seeded entities. Seeded identities must be stable
22+
* across emulator restarts: downstream apps key accounts on the OIDC sub,
23+
* and a regenerated uid for the same seeded user creates a duplicate
24+
* account in the app after every restart.
25+
*/
26+
export function stableUid(prefix: string, seedKey: string): string {
27+
const id = createHash("sha256").update(seedKey).digest("base64url").slice(0, 20);
28+
return `${prefix}_${id}`;
29+
}
30+
2031
export function generateSecret(): string {
2132
return randomBytes(32).toString("base64url");
2233
}

packages/@emulators/vercel/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { Hono } from "@emulators/core";
22
import type { AppEnv, RouteContext, ServicePlugin, Store, WebhookDispatcher, TokenMap } from "@emulators/core";
33
import type { VercelEnvVar } from "./entities.js";
44
import { getVercelStore } from "./store.js";
5-
import { generateUid, nowMs } from "./helpers.js";
5+
import { generateUid, nowMs, stableUid } from "./helpers.js";
66
import { userRoutes } from "./routes/user.js";
77
import { projectsRoutes } from "./routes/projects.js";
88
import { deploymentsRoutes } from "./routes/deployments.js";
99
import { domainsRoutes } from "./routes/domains.js";
1010
import { envRoutes } from "./routes/env.js";
1111
import { oauthRoutes } from "./routes/oauth.js";
1212
import { apiKeysRoutes } from "./routes/api-keys.js";
13+
import { blobRoutes } from "./routes/blob.js";
1314

1415
export { getVercelStore, type VercelStore } from "./store.js";
1516
export * from "./entities.js";
@@ -53,7 +54,7 @@ function seedDefaults(store: Store, _baseUrl: string): void {
5354
const vs = getVercelStore(store);
5455

5556
vs.users.insert({
56-
uid: generateUid("user"),
57+
uid: stableUid("user", "vercel.user.admin"),
5758
email: "admin@localhost",
5859
username: "admin",
5960
name: "Admin",
@@ -75,7 +76,7 @@ export function seedFromConfig(store: Store, baseUrl: string, config: VercelSeed
7576
const existing = vs.users.findOneBy("username", u.username);
7677
if (existing) continue;
7778
vs.users.insert({
78-
uid: generateUid("user"),
79+
uid: stableUid("user", `vercel.user.${u.username}`),
7980
email: u.email ?? `${u.username}@localhost`,
8081
username: u.username,
8182
name: u.name ?? null,
@@ -218,6 +219,7 @@ export const vercelPlugin: ServicePlugin = {
218219
domainsRoutes(ctx);
219220
envRoutes(ctx);
220221
apiKeysRoutes(ctx);
222+
blobRoutes(ctx);
221223
},
222224
seed(store: Store, baseUrl: string): void {
223225
seedDefaults(store, baseUrl);

0 commit comments

Comments
 (0)