Skip to main content

Storage Hooks

Hook into file lifecycle events to validate uploads, log activity, restrict downloads, or run post-processing workflows.

Overview

Storage hooks are defined per-bucket in edgebase.config.ts. They receive file metadata only — file binary data is never passed to hooks due to the 128 MB Worker memory limit.

HookTimingBehaviorCan ModifyCan Reject
beforeUploadBefore R2 putBlockingYes (return metadata)Yes (throw)
afterUploadAfter R2 putNon-blocking (waitUntil)NoNo
beforeDownloadBefore streaming responseBlockingNoYes (throw)
beforeDeleteBefore R2 deleteBlockingNoYes (throw)
afterDeleteAfter R2 deleteNon-blocking (waitUntil)NoNo

Access rules always run before hooks. If a rule rejects the operation, hooks do not execute.

Configuration

// edgebase.config.ts
import { defineConfig } from '@edgebase/shared';

export default defineConfig({
storage: {
buckets: {
avatars: {
access: {
read: () => true,
write: (auth) => auth !== null,
delete: (auth, file) => auth?.id === file.uploadedBy,
},
handlers: {
hooks: {
beforeUpload: async (auth, file, ctx) => { /* ... */ },
afterUpload: async (auth, file, ctx) => { /* ... */ },
beforeDownload: async (auth, file, ctx) => { /* ... */ },
beforeDelete: async (auth, file, ctx) => { /* ... */ },
afterDelete: async (auth, file, ctx) => { /* ... */ },
},
},
},
},
},
});

beforeUpload

Runs before a file is written to R2. Can validate file metadata, reject the upload, or return custom metadata to merge into the file's customMetadata.

beforeUpload: async (auth, file, ctx) => {
// Validate file type
if (!file.contentType.startsWith('image/')) {
throw new Error('Only images allowed in avatars bucket');
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
throw new Error('File too large (max 5MB)');
}
// Return custom metadata to merge into R2 customMetadata
return { processedAt: new Date().toISOString(), uploadedByRole: auth?.role || 'anonymous' };
},
ParameterTypeDescription
authAuthContext | nullAuthenticated user, or null for unauthenticated
fileWriteFileMetaUpload metadata: key, size, contentType
ctxStorageHookCtxHook context

Return value:

  • Return Record<string, string> — merged into the file's customMetadata
  • Return void — upload proceeds without extra metadata
  • Throw — upload is rejected

afterUpload

Runs after a file has been successfully written to R2. Non-blocking via ctx.waitUntil().

afterUpload: async (auth, file, ctx) => {
// Notify user via push notification
if (auth?.id) {
ctx.waitUntil(
ctx.push.send(auth.id, { title: 'Upload complete', body: `${file.key} uploaded` }),
);
}
},
ParameterTypeDescription
authAuthContext | nullAuthenticated user
fileR2FileMetaFinal R2 metadata including etag, uploadedAt, customMetadata
ctxStorageHookCtxHook context

beforeDownload

Runs before the file is streamed to the client. Throw to reject the download.

beforeDownload: async (auth, file, ctx) => {
// Only allow file owner to download
if (auth?.id !== file.uploadedBy) {
throw new Error('You can only download your own files');
}
},
ParameterTypeDescription
authAuthContext | nullAuthenticated user
fileR2FileMetaFile metadata from R2
ctxStorageHookCtxHook context

Return value:

  • Throw — download is rejected
  • Return void — download proceeds

beforeDelete

Runs before a file is deleted from R2. Throw to reject the deletion.

beforeDelete: async (auth, file, ctx) => {
// Prevent deletion of files with "protected" metadata
if (file.customMetadata?.protected === 'true') {
throw new Error('This file is protected and cannot be deleted');
}
},
ParameterTypeDescription
authAuthContext | nullAuthenticated user
fileR2FileMetaFile metadata from R2
ctxStorageHookCtxHook context

Return value:

  • Throw — deletion is rejected
  • Return void — deletion proceeds

afterDelete

Runs after a file has been deleted from R2. Non-blocking via ctx.waitUntil().

afterDelete: async (auth, file, ctx) => {
// Log deletion to external audit service
ctx.waitUntil(
fetch('https://audit.example.com/log', {
method: 'POST',
body: JSON.stringify({
action: 'file_deleted',
key: file.key,
deletedBy: auth?.id,
timestamp: new Date().toISOString(),
}),
}).catch(() => {}),
);
},
ParameterTypeDescription
authAuthContext | nullAuthenticated user
fileR2FileMetaMetadata of the deleted file
ctxStorageHookCtxHook context

Hook Context

StorageHookCtx provides:

PropertyTypeDescription
waitUntil(promise)(p: Promise<unknown>) => voidKeep the Worker alive for background work
push.send(userId, payload)(userId: string, payload: { title?: string; body: string }) => Promise<void>Send a push notification (best-effort)
No DB Access

Storage hooks run in the Worker context (not a Durable Object), so they don't have access to the database. Use push.send() for notifications or waitUntil() for external API calls.

Batch Delete

When using batch delete (POST /:bucket/delete-batch), beforeDelete and afterDelete hooks are executed per file sequentially. If beforeDelete throws for a specific file, that file is skipped and reported in the failed array.

Presigned URL Uploads

Files uploaded via presigned URLs bypass the server entirely and do not trigger storage hooks. Only uploads through the standard upload endpoint trigger hooks.


TypeScript Types

Full type definitions for reference:

interface WriteFileMeta {
key: string;
size: number;
contentType: string;
}

interface R2FileMeta {
key: string;
size: number;
contentType: string;
etag: string;
uploadedAt: string; // ISO timestamp
uploadedBy?: string; // User ID (if authenticated)
customMetadata?: Record<string, string>;
}

interface StorageHookCtx {
waitUntil(promise: Promise<unknown>): void;
push: {
send(userId: string, payload: { title?: string; body: string }): Promise<void>;
};
}

interface StorageHooks {
beforeUpload?: (auth: AuthContext | null, file: WriteFileMeta, ctx: StorageHookCtx) =>
Promise<Record<string, string> | void> | Record<string, string> | void;
afterUpload?: (auth: AuthContext | null, file: R2FileMeta, ctx: StorageHookCtx) =>
Promise<void> | void;
beforeDownload?: (auth: AuthContext | null, file: R2FileMeta, ctx: StorageHookCtx) =>
Promise<void> | void;
beforeDelete?: (auth: AuthContext | null, file: R2FileMeta, ctx: StorageHookCtx) =>
Promise<void> | void;
afterDelete?: (auth: AuthContext | null, file: R2FileMeta, ctx: StorageHookCtx) =>
Promise<void> | void;
}