@minhvuong/pirate-storage
v0.1.2
Published
Self-hosted, provider-oriented file storage helpers. Applications own their deployment, credentials, authentication, receipts, and database.
Readme
Drive storage SDK
Self-hosted, provider-oriented file storage helpers. Applications own their deployment, credentials, authentication, receipts, and database.
Packages
| Package | Purpose |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| @minhvuong/pirate-storage | Core contracts, receipts, orchestration, hooks, Range parsing, journals |
| @minhvuong/pirate-storage-discord | Discord webhook transport and production storage provider |
| @minhvuong/pirate-storage-server | Typed file routes and Fetch/Worker upload handler |
| @minhvuong/pirate-storage-client | Browser multipart uploader |
| @minhvuong/pirate-storage-preview-browser | Optional JPEG/PNG preview Web Worker |
| @minhvuong/pirate-storage-telegram | Telegram provider, media modes, variants, Range reads, signed receipts |
| @minhvuong/pirate-storage-telegram-server | Long-lived Bun/mtcute runtime with SQLite session storage |
| @minhvuong/pirate-storage-telegram-worker | Cloudflare Worker/DO runtime with durable session and upload journal |
All packages are self-hosted helpers. Applications own their credentials, upload routes, receipt database, and authorization policy.
Server or Worker
import { MemoryUploadJournal, createStorage } from "@minhvuong/pirate-storage";
import { createDiscordProvider } from "@minhvuong/pirate-storage-discord";
import {
createFetchHandler,
createFileRoute,
defineFileRouter,
} from "@minhvuong/pirate-storage-server";
const discord = createDiscordProvider({
webhooks: [env.DISCORD_WEBHOOK_1, env.DISCORD_WEBHOOK_2],
strategy: "least-busy", // default; use "round-robin" for deterministic rotation
manifestSecret: env.STORAGE_MANIFEST_SECRET,
partSize: 8 * 1024 * 1024,
attachmentsPerMessage: 4,
uploadConcurrency: 3,
});
const storage = createStorage({
providers: { discord },
defaultProvider: "discord",
});
const f = createFileRoute();
const router = defineFileRouter({
attachments: f({
provider: "discord",
accept: ["image/*", "video/*", "application/pdf"],
maxFileSize: 2_000_000_000,
})
.input((value) => uploadInputSchema.parse(value))
.middleware(async ({ request }) => {
const user = await requireUser(request);
return { userId: user.id };
})
.onUploadComplete(async ({ receipt, metadata }) => {
// Prisma, Drizzle, D1, Convex, raw SQL, or anything else belongs here.
return database.files.create({ receipt, ownerId: metadata.userId });
}),
});
export const handleStorage = createFetchHandler({
storage,
router,
journal: new MemoryUploadJournal(), // Development only; use durable storage in production.
});The handler is Fetch-standard and can be mounted in a Cloudflare Worker, TanStack Start server
route, Next route handler, Bun server, or any runtime that accepts Request and returns Response.
The Discord pool assigns one webhook to each complete upload. Receipt connectionId records the
safe webhook identity without its token, allowing reads, resume, refresh, and deletion to route to
the correct configured webhook after a restart. Keep the old webhook configured for as long as its
receipts remain stored. For one connection, webhookUrl: env.DISCORD_WEBHOOK_URL remains supported.
Browser
import { createUploader } from "@minhvuong/pirate-storage-client";
import { createBrowserImagePreview } from "@minhvuong/pirate-storage-preview-browser";
const uploader = createUploader({
endpoint: "/api/storage",
headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
previewGenerators: [createBrowserImagePreview()],
});
const task = uploader.createUpload("attachments", file, {
input: { folderId },
concurrency: 3,
onProgress: console.log,
});
task.pause();
task.resume();
const { receipt, serverData } = await task.done;The browser uploads bounded parts to the application's authenticated handler. It never receives a Discord webhook or bot credential. Discord has no scoped presigned-upload mechanism.
Imperative API
const receipt = await storage.upload(file, { provider: "discord" });
const receipts = await storage.uploadMany(files, { provider: "discord" });
const remote = await storage.uploadFromUrl(url, { provider: "discord" });
await storage.stat(receipt);
await storage.verify(receipt);
const response = await storage.open(receipt, { range: "bytes=0-1048575" });
const httpResponse = await storage.toResponse(receipt, request);
const preview = await storage.preview(receipt, { width: 800, format: "webp" });
const freshLinks = await storage.refresh(receipt);
await storage.delete(receipt);Grouped images
Discord and Telegram support 2–10 images as one logical group with one durable receipt:
const group = await storage.uploadGroup([cover, page1, page2], {
provider: "discord", // or "telegram"
});
await storage.verifyGroup(group);
const image = await storage.openGroupItem(group, group.items[0].id);
const preview = await storage.previewGroupItem(group, group.items[0].id, { width: 640 });
await storage.deleteGroup(group); // deletes the complete group and its manifestThis first release treats the group as one lifecycle unit. It does not remove one item from an existing group. Upload independently when images need independent deletion.
- Discord stores all images in one message and a separate signed manifest message. Each image has
the configured
partSizelimit independently: 8 MiB by default, up to 10 MiB. Native image attachments may have metadata stripped, so group items conservatively reportexactOriginal: falseand expose their provider-side size understored. - Telegram stores a native media album (Telegram represents it as grouped messages) and a separate signed manifest document. Each image must be JPEG, PNG, or WebP and at most 10 MiB.
Provider URLs, webhook tokens, bot tokens, and manifest secrets never enter the group receipt. Persist the complete group receipt JSON in the consumer database just like a normal receipt.
Providers declare capabilities instead of being forced to implement features they cannot support.
Future providers can omit Range reads, multipart upload, deletion, preview generation, or transforms
and expose those limitations through provider.capabilities.
Telegram
The runtime-specific package supplies the provider while the shared storage API remains unchanged:
const telegram = createTelegramServerProvider({
apiId: env.TELEGRAM_API_ID,
apiHash: env.TELEGRAM_API_HASH,
botToken: env.TELEGRAM_BOT_TOKEN,
channel: Number(env.TELEGRAM_CHANNEL_ID),
channelId: env.TELEGRAM_CHANNEL_ID,
sessionPath: ".data/telegram.sqlite",
manifestSecret: env.STORAGE_MANIFEST_SECRET,
});
const storage = createStorage({ providers: { telegram: telegram.provider } });
await storage.upload(video, {
provider: "telegram",
providerOptions: {
mode: "video",
width: 1920,
height: 1080,
duration: 42,
supportsStreaming: true,
},
});On Cloudflare, instantiate the Worker runtime inside one Durable Object and use
DurableObjectUploadJournal. Browser parts default to 32 MiB, remain below common Worker request
body limits, and are split internally into 512 KiB MTProto calls. Telegram combines those temporary
parts into one final message.
The route builder maps validated input to provider-only settings without exposing credentials:
f({ provider: "telegram", accept: ["*/*"] })
.input((value) => mediaInputSchema.parse(value))
.providerOptions(({ input }): TelegramUploadOptions => input.media);Production notes
- Store the complete versioned receipt JSON in the consumer's database.
- Never persist Discord CDN URLs; they are signed and expire.
- Use at least 32 random bytes for
STORAGE_MANIFEST_SECRETand back it up. - Replace
MemoryUploadJournalwith a Durable Object, D1, Redis, or another atomic durable adapter. - Protect every upload-session endpoint with application authentication.
- Restrict
uploadFromUrldestinations to avoid SSRF in server applications. - Discord image proxy transforms are experimental and should have an application fallback.
- Telegram
photomode is transformed by Telegram; usedocumentfor exact original bytes. - Telegram multipart resume only lasts while Telegram retains temporary upload parts. Treat
SESSION_EXPIREDas a restart signal.
