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
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.9.1",
"version": "2.9.2",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import type { VercelBlobHandleUploadBody } from "../api/storage-adapter";

// Top-level vi.mock calls are hoisted by Vitest before any imports.
// Factories are used so the packages do not need to be installed as devDependencies.
Expand Down Expand Up @@ -271,17 +272,21 @@ describe("vercelBlobAdapter", () => {
const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
const adapter = vercelBlobAdapter();

const body = {
const body: VercelBlobHandleUploadBody = {
type: "blob.generate-client-token",
payload: { pathname: "photo.jpg" },
payload: {
pathname: "photo.jpg",
multipart: false,
clientPayload: null,
},
};
const request = new Request("https://example.com/api/upload", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});

const result = await adapter.handleRequest(request, {});
const result = await adapter.handleRequest(request, body, {});

expect(mockHandleUpload).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -300,17 +305,21 @@ describe("vercelBlobAdapter", () => {
const adapter = vercelBlobAdapter();

const onBeforeGenerateToken = vi.fn().mockResolvedValue(undefined);
const body = {
const body: VercelBlobHandleUploadBody = {
type: "blob.generate-client-token",
payload: { pathname: "test.jpg" },
payload: {
pathname: "test.jpg",
multipart: false,
clientPayload: null,
},
};
const request = new Request("https://example.com/api/upload", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});

await adapter.handleRequest(request, { onBeforeGenerateToken });
await adapter.handleRequest(request, body, { onBeforeGenerateToken });

// Verify that handleUpload received an onBeforeGenerateToken callback
const callArgs = mockHandleUpload.mock.calls[0]![0] as Record<
Expand All @@ -325,6 +334,41 @@ describe("vercelBlobAdapter", () => {
expect(onBeforeGenerateToken).toHaveBeenCalledWith("test.jpg", null);
});

it("reuses the already-parsed request body without reading the request again", async () => {
const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
const adapter = vercelBlobAdapter();

const body: VercelBlobHandleUploadBody = {
type: "blob.generate-client-token",
payload: {
pathname: "consumed.jpg",
multipart: false,
clientPayload: null,
},
};
const request = new Request("https://example.com/api/upload", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});

// Simulate the BTST route layer parsing the request before the adapter runs.
const parsedBody = await request.json();

const result = await adapter.handleRequest(request, parsedBody, {});

expect(mockHandleUpload).toHaveBeenCalledWith(
expect.objectContaining({
body: parsedBody,
request,
}),
);
expect(result).toEqual({
type: "blob.generate-client-token",
clientToken: "tok123",
});
});

it("calls del when deleting a blob by URL", async () => {
const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
const adapter = vercelBlobAdapter();
Expand Down
8 changes: 4 additions & 4 deletions packages/stack/src/plugins/media/api/adapters/vercel-blob.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
VercelBlobStorageAdapter,
VercelBlobHandlerCallbacks,
VercelBlobHandleUploadBody,
} from "../storage-adapter";

export interface VercelBlobStorageAdapterOptions {
Expand All @@ -17,7 +18,7 @@ export interface VercelBlobStorageAdapterOptions {
* Defined inline so we do not hard-depend on a specific `@vercel/blob` release.
*/
interface HandleUploadOptions {
body: unknown;
body: VercelBlobHandleUploadBody;
request: Request;
token?: string;
onBeforeGenerateToken: (
Expand Down Expand Up @@ -73,13 +74,14 @@ export function vercelBlobAdapter(

async handleRequest(
request: Request,
body: VercelBlobHandleUploadBody,
callbacks: VercelBlobHandlerCallbacks,
): Promise<unknown> {
let handleUpload: HandleUploadFn;
try {
const vercelBlobClient =
/* @vite-ignore */
(await import("@vercel/blob/client")) as {
(await import("@vercel/blob/client")) as unknown as {
handleUpload: HandleUploadFn;
};
({ handleUpload } = vercelBlobClient);
Expand All @@ -91,8 +93,6 @@ export function vercelBlobAdapter(
);
}

const body = await request.json();

return handleUpload({
body,
request,
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/plugins/media/api/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) =>
});
}

return storageAdapter.handleRequest(ctx.request, {
return storageAdapter.handleRequest(ctx.request, ctx.body, {
onBeforeGenerateToken: async (pathname, clientPayload) => {
const filename = pathname.split("/").pop() ?? pathname;
let parsed: Record<string, unknown> = {};
Expand Down
32 changes: 32 additions & 0 deletions packages/stack/src/plugins/media/api/storage-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@ export interface VercelBlobTokenOptions {
maximumSizeInBytes?: number;
}

/**
* Minimal blob metadata sent back by Vercel Blob's upload completion callback.
* Keep this intentionally small so BTST does not hard-depend on a specific SDK type.
*/
export interface VercelBlobCallbackBlob {
url: string;
pathname: string;
[key: string]: unknown;
}

export interface VercelBlobGenerateClientTokenBody {
type: "blob.generate-client-token";
payload: {
pathname: string;
multipart: boolean;
clientPayload: string | null;
};
}

export interface VercelBlobUploadCompletedBody {
type: "blob.upload-completed";
payload: {
blob: VercelBlobCallbackBlob;
tokenPayload?: string | null;
};
}

export type VercelBlobHandleUploadBody =
| VercelBlobGenerateClientTokenBody
| VercelBlobUploadCompletedBody;

/**
* Callbacks provided to the Vercel Blob adapter when handling a request.
*/
Expand Down Expand Up @@ -107,6 +138,7 @@ export interface VercelBlobStorageAdapter {
*/
handleRequest(
request: Request,
body: VercelBlobHandleUploadBody,
callbacks: VercelBlobHandlerCallbacks,
): Promise<unknown>;
/**
Expand Down
Loading