@minhvuong/pirate-storage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/dist/errors.d.ts +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +484 -0
- package/dist/journal.d.ts +34 -0
- package/dist/provider.d.ts +5 -0
- package/dist/range.d.ts +5 -0
- package/dist/receipt.d.ts +23 -0
- package/dist/source.d.ts +4 -0
- package/dist/storage.d.ts +50 -0
- package/dist/types.d.ts +154 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Drive storage SDK
|
|
2
|
+
|
|
3
|
+
Self-hosted, provider-oriented file storage helpers. Applications own their deployment,
|
|
4
|
+
credentials, authentication, receipts, and database.
|
|
5
|
+
|
|
6
|
+
## Packages
|
|
7
|
+
|
|
8
|
+
| Package | Purpose |
|
|
9
|
+
| -------------------------------- | ----------------------------------------------------------------------- |
|
|
10
|
+
| `@minhvuong/pirate-storage` | Core contracts, receipts, orchestration, hooks, Range parsing, journals |
|
|
11
|
+
| `@drive/storage-discord` | Discord webhook transport and production storage provider |
|
|
12
|
+
| `@drive/storage-server` | Typed file routes and Fetch/Worker upload handler |
|
|
13
|
+
| `@drive/storage-client` | Browser multipart uploader |
|
|
14
|
+
| `@drive/storage-preview-browser` | Optional JPEG/PNG preview Web Worker |
|
|
15
|
+
|
|
16
|
+
`@drive/*` is the internal workspace scope. Replace it with an owned registry scope before public
|
|
17
|
+
publication.
|
|
18
|
+
|
|
19
|
+
## Server or Worker
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { MemoryUploadJournal, createStorage } from "@minhvuong/pirate-storage";
|
|
23
|
+
import { createDiscordProvider } from "@drive/storage-discord";
|
|
24
|
+
import { createFetchHandler, createFileRoute, defineFileRouter } from "@drive/storage-server";
|
|
25
|
+
|
|
26
|
+
const discord = createDiscordProvider({
|
|
27
|
+
webhookUrl: env.DISCORD_WEBHOOK_URL,
|
|
28
|
+
manifestSecret: env.STORAGE_MANIFEST_SECRET,
|
|
29
|
+
partSize: 8 * 1024 * 1024,
|
|
30
|
+
attachmentsPerMessage: 4,
|
|
31
|
+
uploadConcurrency: 3,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const storage = createStorage({
|
|
35
|
+
providers: { discord },
|
|
36
|
+
defaultProvider: "discord",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const f = createFileRoute();
|
|
40
|
+
const router = defineFileRouter({
|
|
41
|
+
attachments: f({
|
|
42
|
+
provider: "discord",
|
|
43
|
+
accept: ["image/*", "video/*", "application/pdf"],
|
|
44
|
+
maxFileSize: 2_000_000_000,
|
|
45
|
+
})
|
|
46
|
+
.input((value) => uploadInputSchema.parse(value))
|
|
47
|
+
.middleware(async ({ request }) => {
|
|
48
|
+
const user = await requireUser(request);
|
|
49
|
+
return { userId: user.id };
|
|
50
|
+
})
|
|
51
|
+
.onUploadComplete(async ({ receipt, metadata }) => {
|
|
52
|
+
// Prisma, Drizzle, D1, Convex, raw SQL, or anything else belongs here.
|
|
53
|
+
return database.files.create({ receipt, ownerId: metadata.userId });
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const handleStorage = createFetchHandler({
|
|
58
|
+
storage,
|
|
59
|
+
router,
|
|
60
|
+
journal: new MemoryUploadJournal(), // Development only; use durable storage in production.
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The handler is Fetch-standard and can be mounted in a Cloudflare Worker, TanStack Start server
|
|
65
|
+
route, Next route handler, Bun server, or any runtime that accepts `Request` and returns `Response`.
|
|
66
|
+
|
|
67
|
+
## Browser
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createUploader } from "@drive/storage-client";
|
|
71
|
+
import { createBrowserImagePreview } from "@drive/storage-preview-browser";
|
|
72
|
+
|
|
73
|
+
const uploader = createUploader({
|
|
74
|
+
endpoint: "/api/storage",
|
|
75
|
+
headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
|
|
76
|
+
previewGenerators: [createBrowserImagePreview()],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const task = uploader.createUpload("attachments", file, {
|
|
80
|
+
input: { folderId },
|
|
81
|
+
concurrency: 3,
|
|
82
|
+
onProgress: console.log,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
task.pause();
|
|
86
|
+
task.resume();
|
|
87
|
+
const { receipt, serverData } = await task.done;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The browser uploads bounded parts to the application's authenticated handler. It never receives a
|
|
91
|
+
Discord webhook or bot credential. Discord has no scoped presigned-upload mechanism.
|
|
92
|
+
|
|
93
|
+
## Imperative API
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const receipt = await storage.upload(file, { provider: "discord" });
|
|
97
|
+
const receipts = await storage.uploadMany(files, { provider: "discord" });
|
|
98
|
+
const remote = await storage.uploadFromUrl(url, { provider: "discord" });
|
|
99
|
+
|
|
100
|
+
await storage.stat(receipt);
|
|
101
|
+
await storage.verify(receipt);
|
|
102
|
+
const response = await storage.open(receipt, { range: "bytes=0-1048575" });
|
|
103
|
+
const httpResponse = await storage.toResponse(receipt, request);
|
|
104
|
+
const preview = await storage.preview(receipt, { width: 800, format: "webp" });
|
|
105
|
+
const freshLinks = await storage.refresh(receipt);
|
|
106
|
+
await storage.delete(receipt);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Providers declare capabilities instead of being forced to implement features they cannot support.
|
|
110
|
+
Future providers can omit Range reads, multipart upload, deletion, preview generation, or transforms
|
|
111
|
+
and expose those limitations through `provider.capabilities`.
|
|
112
|
+
|
|
113
|
+
## Production notes
|
|
114
|
+
|
|
115
|
+
- Store the complete versioned receipt JSON in the consumer's database.
|
|
116
|
+
- Never persist Discord CDN URLs; they are signed and expire.
|
|
117
|
+
- Use at least 32 random bytes for `STORAGE_MANIFEST_SECRET` and back it up.
|
|
118
|
+
- Replace `MemoryUploadJournal` with a Durable Object, D1, Redis, or another atomic durable adapter.
|
|
119
|
+
- Protect every upload-session endpoint with application authentication.
|
|
120
|
+
- Restrict `uploadFromUrl` destinations to avoid SSRF in server applications.
|
|
121
|
+
- Discord image proxy transforms are experimental and should have an application fallback.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type StorageErrorCode = "INVALID_INPUT" | "INVALID_RECEIPT" | "PROVIDER_NOT_FOUND" | "CAPABILITY_UNSUPPORTED" | "FILE_TOO_LARGE" | "UPLOAD_FAILED" | "READ_FAILED" | "DELETE_FAILED" | "INTEGRITY_FAILED" | "RANGE_NOT_SATISFIABLE" | "SESSION_NOT_FOUND" | "SESSION_EXPIRED";
|
|
2
|
+
export declare class StorageError extends Error {
|
|
3
|
+
readonly code: StorageErrorCode;
|
|
4
|
+
readonly status?: number;
|
|
5
|
+
readonly cause?: unknown;
|
|
6
|
+
constructor(code: StorageErrorCode, message: string, options?: {
|
|
7
|
+
status?: number;
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare class UnsupportedCapabilityError extends StorageError {
|
|
12
|
+
constructor(provider: string, capability: string);
|
|
13
|
+
}
|
|
14
|
+
export declare class RangeNotSatisfiableError extends StorageError {
|
|
15
|
+
constructor(message?: string);
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
class StorageError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
cause;
|
|
6
|
+
constructor(code, message, options = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "StorageError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = options.status;
|
|
11
|
+
this.cause = options.cause;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class UnsupportedCapabilityError extends StorageError {
|
|
16
|
+
constructor(provider, capability) {
|
|
17
|
+
super("CAPABILITY_UNSUPPORTED", `Provider '${provider}' does not support ${capability}`, {
|
|
18
|
+
status: 501
|
|
19
|
+
});
|
|
20
|
+
this.name = "UnsupportedCapabilityError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class RangeNotSatisfiableError extends StorageError {
|
|
25
|
+
constructor(message = "The requested byte range is not satisfiable") {
|
|
26
|
+
super("RANGE_NOT_SATISFIABLE", message, { status: 416 });
|
|
27
|
+
this.name = "RangeNotSatisfiableError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// src/journal.ts
|
|
31
|
+
class MemoryUploadJournal {
|
|
32
|
+
#sessions = new Map;
|
|
33
|
+
#queues = new Map;
|
|
34
|
+
async create(session) {
|
|
35
|
+
this.#prune();
|
|
36
|
+
if (this.#sessions.has(session.id)) {
|
|
37
|
+
throw new StorageError("INVALID_INPUT", `Upload session '${session.id}' already exists`);
|
|
38
|
+
}
|
|
39
|
+
this.#sessions.set(session.id, structuredClone(session));
|
|
40
|
+
}
|
|
41
|
+
async get(id) {
|
|
42
|
+
this.#prune();
|
|
43
|
+
const session = this.#sessions.get(id);
|
|
44
|
+
return session ? structuredClone(session) : undefined;
|
|
45
|
+
}
|
|
46
|
+
putPart(id, part) {
|
|
47
|
+
return this.#mutate(id, (session) => {
|
|
48
|
+
if (session.status !== "active") {
|
|
49
|
+
throw new StorageError("INVALID_INPUT", `Upload session '${id}' is not active`);
|
|
50
|
+
}
|
|
51
|
+
session.parts[String(part.index)] = structuredClone(part);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
setStatus(id, status) {
|
|
55
|
+
return this.#mutate(id, (session) => {
|
|
56
|
+
session.status = status;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
setResult(id, result) {
|
|
60
|
+
return this.#mutate(id, (session) => {
|
|
61
|
+
session.status = "complete";
|
|
62
|
+
session.result = structuredClone(result);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async remove(id) {
|
|
66
|
+
await this.#queues.get(id)?.catch(() => {
|
|
67
|
+
return;
|
|
68
|
+
});
|
|
69
|
+
this.#sessions.delete(id);
|
|
70
|
+
this.#queues.delete(id);
|
|
71
|
+
}
|
|
72
|
+
#mutate(id, update) {
|
|
73
|
+
const previous = this.#queues.get(id) ?? Promise.resolve();
|
|
74
|
+
const operation = previous.then(() => {
|
|
75
|
+
this.#prune();
|
|
76
|
+
const current = this.#sessions.get(id);
|
|
77
|
+
if (!current) {
|
|
78
|
+
throw new StorageError("SESSION_NOT_FOUND", `Upload session '${id}' was not found`, {
|
|
79
|
+
status: 404
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const session = structuredClone(current);
|
|
83
|
+
update(session);
|
|
84
|
+
this.#sessions.set(id, session);
|
|
85
|
+
return structuredClone(session);
|
|
86
|
+
});
|
|
87
|
+
this.#queues.set(id, operation);
|
|
88
|
+
operation.then(() => {
|
|
89
|
+
if (this.#queues.get(id) === operation)
|
|
90
|
+
this.#queues.delete(id);
|
|
91
|
+
}, () => {
|
|
92
|
+
if (this.#queues.get(id) === operation)
|
|
93
|
+
this.#queues.delete(id);
|
|
94
|
+
});
|
|
95
|
+
return operation;
|
|
96
|
+
}
|
|
97
|
+
#prune() {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
for (const [id, session] of this.#sessions) {
|
|
100
|
+
if (Date.parse(session.expiresAt) <= now)
|
|
101
|
+
this.#sessions.delete(id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// src/provider.ts
|
|
106
|
+
var capabilityDefaults = Object.freeze({
|
|
107
|
+
upload: false,
|
|
108
|
+
uploadStream: false,
|
|
109
|
+
multipartUpload: false,
|
|
110
|
+
parallelUpload: false,
|
|
111
|
+
resumableUpload: false,
|
|
112
|
+
read: false,
|
|
113
|
+
rangeRead: false,
|
|
114
|
+
delete: false,
|
|
115
|
+
preview: false,
|
|
116
|
+
nativePreview: false,
|
|
117
|
+
imageTransform: "none",
|
|
118
|
+
directClientUpload: false
|
|
119
|
+
});
|
|
120
|
+
function providerCapabilities(capabilities) {
|
|
121
|
+
return Object.freeze({ ...capabilityDefaults, ...capabilities });
|
|
122
|
+
}
|
|
123
|
+
function defineStorageProvider(provider) {
|
|
124
|
+
return provider;
|
|
125
|
+
}
|
|
126
|
+
// src/range.ts
|
|
127
|
+
function parseByteRange(header, size) {
|
|
128
|
+
if (!header)
|
|
129
|
+
return null;
|
|
130
|
+
if (!header.startsWith("bytes=") || header.includes(",") || size <= 0) {
|
|
131
|
+
throw new RangeNotSatisfiableError;
|
|
132
|
+
}
|
|
133
|
+
const value = header.slice("bytes=".length).trim();
|
|
134
|
+
const separator = value.indexOf("-");
|
|
135
|
+
if (separator === -1)
|
|
136
|
+
throw new RangeNotSatisfiableError;
|
|
137
|
+
const rawStart = value.slice(0, separator).trim();
|
|
138
|
+
const rawEnd = value.slice(separator + 1).trim();
|
|
139
|
+
if (!rawStart && !rawEnd)
|
|
140
|
+
throw new RangeNotSatisfiableError;
|
|
141
|
+
if (!rawStart) {
|
|
142
|
+
const suffixLength = Number(rawEnd);
|
|
143
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
|
|
144
|
+
throw new RangeNotSatisfiableError;
|
|
145
|
+
}
|
|
146
|
+
return { start: Math.max(0, size - suffixLength), end: size - 1 };
|
|
147
|
+
}
|
|
148
|
+
const start = Number(rawStart);
|
|
149
|
+
const requestedEnd = rawEnd ? Number(rawEnd) : size - 1;
|
|
150
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || requestedEnd < start || start >= size) {
|
|
151
|
+
throw new RangeNotSatisfiableError;
|
|
152
|
+
}
|
|
153
|
+
return { start, end: Math.min(requestedEnd, size - 1) };
|
|
154
|
+
}
|
|
155
|
+
// src/receipt.ts
|
|
156
|
+
import { z } from "zod";
|
|
157
|
+
var storedFileSchema = z.object({
|
|
158
|
+
name: z.string().min(1).max(255),
|
|
159
|
+
contentType: z.string().min(1).max(200),
|
|
160
|
+
size: z.number().int().positive(),
|
|
161
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/)
|
|
162
|
+
});
|
|
163
|
+
var previewSchema = z.object({
|
|
164
|
+
contentType: z.string().regex(/^image\//),
|
|
165
|
+
size: z.number().int().positive(),
|
|
166
|
+
width: z.number().int().positive(),
|
|
167
|
+
height: z.number().int().positive()
|
|
168
|
+
});
|
|
169
|
+
var storageReceiptSchema = z.object({
|
|
170
|
+
version: z.literal(1),
|
|
171
|
+
provider: z.string().min(1),
|
|
172
|
+
connectionId: z.string().min(1),
|
|
173
|
+
createdAt: z.string().datetime(),
|
|
174
|
+
file: storedFileSchema,
|
|
175
|
+
locator: z.unknown(),
|
|
176
|
+
preview: previewSchema.optional()
|
|
177
|
+
});
|
|
178
|
+
function parseReceipt(value) {
|
|
179
|
+
const result = storageReceiptSchema.safeParse(value);
|
|
180
|
+
if (!result.success) {
|
|
181
|
+
throw new StorageError("INVALID_RECEIPT", "The storage receipt is invalid", {
|
|
182
|
+
status: 400,
|
|
183
|
+
cause: result.error
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return result.data;
|
|
187
|
+
}
|
|
188
|
+
function serializeReceipt(receipt) {
|
|
189
|
+
return JSON.stringify(storageReceiptSchema.parse(receipt));
|
|
190
|
+
}
|
|
191
|
+
// src/source.ts
|
|
192
|
+
function normalizeUploadSource(input) {
|
|
193
|
+
if (typeof File !== "undefined" && input instanceof File) {
|
|
194
|
+
return {
|
|
195
|
+
name: input.name,
|
|
196
|
+
contentType: input.type || "application/octet-stream",
|
|
197
|
+
size: input.size,
|
|
198
|
+
stream: () => input.stream(),
|
|
199
|
+
blob: input
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (!("body" in input)) {
|
|
203
|
+
throw new StorageError("INVALID_INPUT", "Expected a File or streaming upload descriptor");
|
|
204
|
+
}
|
|
205
|
+
const streamFactory = typeof input.body === "function" ? input.body : once(input.body);
|
|
206
|
+
const source = {
|
|
207
|
+
name: input.name,
|
|
208
|
+
contentType: input.contentType || "application/octet-stream",
|
|
209
|
+
size: input.size,
|
|
210
|
+
stream: streamFactory
|
|
211
|
+
};
|
|
212
|
+
validateUploadSource(source);
|
|
213
|
+
return source;
|
|
214
|
+
}
|
|
215
|
+
function validateUploadSource(source, maxFileSize) {
|
|
216
|
+
if (!source.name || source.name.length > 255 || !Number.isSafeInteger(source.size)) {
|
|
217
|
+
throw new StorageError("INVALID_INPUT", "Choose a valid, non-empty file", { status: 400 });
|
|
218
|
+
}
|
|
219
|
+
if (source.size <= 0) {
|
|
220
|
+
throw new StorageError("INVALID_INPUT", "Choose a non-empty file", { status: 400 });
|
|
221
|
+
}
|
|
222
|
+
if (maxFileSize !== undefined && source.size > maxFileSize) {
|
|
223
|
+
throw new StorageError("FILE_TOO_LARGE", `File exceeds the provider's ${maxFileSize} byte limit`, { status: 413 });
|
|
224
|
+
}
|
|
225
|
+
if (!source.contentType || source.contentType.length > 200) {
|
|
226
|
+
throw new StorageError("INVALID_INPUT", "Choose a valid file content type", { status: 400 });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function* streamParts(body, expectedSize, partSize) {
|
|
230
|
+
const reader = body.getReader();
|
|
231
|
+
let total = 0;
|
|
232
|
+
let offset = 0;
|
|
233
|
+
let part = new Uint8Array(Math.min(partSize, expectedSize));
|
|
234
|
+
try {
|
|
235
|
+
while (true) {
|
|
236
|
+
const result = await reader.read();
|
|
237
|
+
if (result.done)
|
|
238
|
+
break;
|
|
239
|
+
if (total + result.value.byteLength > expectedSize) {
|
|
240
|
+
throw new StorageError("INVALID_INPUT", "Upload stream exceeds its declared size", {
|
|
241
|
+
status: 400
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
let inputOffset = 0;
|
|
245
|
+
while (inputOffset < result.value.byteLength) {
|
|
246
|
+
const amount = Math.min(part.byteLength - offset, result.value.byteLength - inputOffset);
|
|
247
|
+
part.set(result.value.subarray(inputOffset, inputOffset + amount), offset);
|
|
248
|
+
inputOffset += amount;
|
|
249
|
+
offset += amount;
|
|
250
|
+
total += amount;
|
|
251
|
+
if (offset === part.byteLength) {
|
|
252
|
+
yield part;
|
|
253
|
+
offset = 0;
|
|
254
|
+
if (total < expectedSize)
|
|
255
|
+
part = new Uint8Array(Math.min(partSize, expectedSize - total));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (total !== expectedSize) {
|
|
260
|
+
throw new StorageError("INVALID_INPUT", "Upload stream ended before its declared size", {
|
|
261
|
+
status: 400
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (offset > 0)
|
|
265
|
+
yield part.subarray(0, offset);
|
|
266
|
+
} finally {
|
|
267
|
+
await reader.cancel().catch(() => {
|
|
268
|
+
return;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function once(stream) {
|
|
273
|
+
let consumed = false;
|
|
274
|
+
return () => {
|
|
275
|
+
if (consumed) {
|
|
276
|
+
throw new StorageError("INVALID_INPUT", "This upload stream has already been consumed");
|
|
277
|
+
}
|
|
278
|
+
consumed = true;
|
|
279
|
+
return stream;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// src/storage.ts
|
|
283
|
+
class Storage {
|
|
284
|
+
#providers;
|
|
285
|
+
#defaultProvider;
|
|
286
|
+
#previewGenerators;
|
|
287
|
+
#hooks;
|
|
288
|
+
constructor(options) {
|
|
289
|
+
this.#providers = options.providers;
|
|
290
|
+
this.#defaultProvider = options.defaultProvider;
|
|
291
|
+
this.#previewGenerators = options.previewGenerators ?? [];
|
|
292
|
+
this.#hooks = options.hooks;
|
|
293
|
+
}
|
|
294
|
+
capabilities(provider) {
|
|
295
|
+
return this.#provider(provider).capabilities;
|
|
296
|
+
}
|
|
297
|
+
provider(provider) {
|
|
298
|
+
return this.#provider(provider);
|
|
299
|
+
}
|
|
300
|
+
async upload(input, options = {}) {
|
|
301
|
+
const providerName = options.provider ?? this.#defaultProvider;
|
|
302
|
+
if (!providerName) {
|
|
303
|
+
throw new StorageError("PROVIDER_NOT_FOUND", "An upload provider is required", {
|
|
304
|
+
status: 400
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const provider = this.#provider(providerName);
|
|
308
|
+
const source = normalizeUploadSource(input);
|
|
309
|
+
validateUploadSource(source, provider.capabilities.maxFileSize);
|
|
310
|
+
try {
|
|
311
|
+
const preview = await this.#preparePreview(source, options);
|
|
312
|
+
const receipt = await provider.upload(source, {
|
|
313
|
+
signal: options.signal,
|
|
314
|
+
preview,
|
|
315
|
+
concurrency: options.concurrency,
|
|
316
|
+
metadata: options.metadata,
|
|
317
|
+
onProgress: options.onProgress
|
|
318
|
+
});
|
|
319
|
+
await this.#hooks?.onUploadComplete?.({
|
|
320
|
+
receipt,
|
|
321
|
+
metadata: options.metadata
|
|
322
|
+
});
|
|
323
|
+
return receipt;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
await this.#hooks?.onUploadError?.({ error, provider: providerName, source });
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async uploadMany(inputs, options = {}) {
|
|
330
|
+
const concurrency = positiveInteger(options.fileConcurrency ?? 2, "fileConcurrency");
|
|
331
|
+
const results = new Array(inputs.length);
|
|
332
|
+
let cursor = 0;
|
|
333
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, async () => {
|
|
334
|
+
while (cursor < inputs.length) {
|
|
335
|
+
const index = cursor;
|
|
336
|
+
cursor += 1;
|
|
337
|
+
const input = inputs[index];
|
|
338
|
+
if (input)
|
|
339
|
+
results[index] = await this.upload(input, options);
|
|
340
|
+
}
|
|
341
|
+
}));
|
|
342
|
+
return results;
|
|
343
|
+
}
|
|
344
|
+
async uploadFromUrl(value, options = {}) {
|
|
345
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
346
|
+
const response = await fetchImplementation(value, {
|
|
347
|
+
...options.request,
|
|
348
|
+
signal: options.signal ?? options.request?.signal
|
|
349
|
+
});
|
|
350
|
+
if (!response.ok || !response.body) {
|
|
351
|
+
throw new StorageError("INVALID_INPUT", `Remote upload source failed (${response.status})`, {
|
|
352
|
+
status: 400
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const length = Number(response.headers.get("content-length"));
|
|
356
|
+
if (!Number.isSafeInteger(length) || length <= 0) {
|
|
357
|
+
await response.body.cancel().catch(() => {
|
|
358
|
+
return;
|
|
359
|
+
});
|
|
360
|
+
throw new StorageError("INVALID_INPUT", "Remote upload source must return a positive Content-Length", { status: 400 });
|
|
361
|
+
}
|
|
362
|
+
const url = new URL(response.url || String(value));
|
|
363
|
+
const fallbackName = decodeURIComponent(url.pathname.split("/").filter(Boolean).at(-1) ?? "download");
|
|
364
|
+
return this.upload({
|
|
365
|
+
name: options.name ?? fallbackName,
|
|
366
|
+
contentType: options.contentType ?? response.headers.get("content-type")?.split(";", 1)[0] ?? "application/octet-stream",
|
|
367
|
+
size: length,
|
|
368
|
+
body: response.body
|
|
369
|
+
}, options);
|
|
370
|
+
}
|
|
371
|
+
async stat(receiptValue) {
|
|
372
|
+
const receipt = parseReceipt(receiptValue);
|
|
373
|
+
return this.#providerForReceipt(receipt).stat(receipt);
|
|
374
|
+
}
|
|
375
|
+
async verify(receiptValue) {
|
|
376
|
+
await this.stat(receiptValue);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
async open(receiptValue, options = {}) {
|
|
380
|
+
const receipt = parseReceipt(receiptValue);
|
|
381
|
+
const provider = this.#providerForReceipt(receipt);
|
|
382
|
+
if (!provider.open)
|
|
383
|
+
throw new UnsupportedCapabilityError(provider.id, "reads");
|
|
384
|
+
return provider.open(receipt, options);
|
|
385
|
+
}
|
|
386
|
+
async toResponse(receiptValue, request) {
|
|
387
|
+
const url = new URL(request.url);
|
|
388
|
+
return this.open(receiptValue, {
|
|
389
|
+
method: request.method === "HEAD" ? "HEAD" : "GET",
|
|
390
|
+
range: request.headers.get("range"),
|
|
391
|
+
download: url.searchParams.get("download") === "1",
|
|
392
|
+
signal: request.signal
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async preview(receiptValue, options = {}) {
|
|
396
|
+
const receipt = parseReceipt(receiptValue);
|
|
397
|
+
const provider = this.#providerForReceipt(receipt);
|
|
398
|
+
if (!provider.preview)
|
|
399
|
+
throw new UnsupportedCapabilityError(provider.id, "previews");
|
|
400
|
+
return provider.preview(receipt, options);
|
|
401
|
+
}
|
|
402
|
+
async refresh(receiptValue) {
|
|
403
|
+
const receipt = parseReceipt(receiptValue);
|
|
404
|
+
const provider = this.#providerForReceipt(receipt);
|
|
405
|
+
if (!provider.refresh)
|
|
406
|
+
throw new UnsupportedCapabilityError(provider.id, "URL refresh");
|
|
407
|
+
return provider.refresh(receipt);
|
|
408
|
+
}
|
|
409
|
+
async delete(receiptValue, options = {}) {
|
|
410
|
+
const receipt = parseReceipt(receiptValue);
|
|
411
|
+
const provider = this.#providerForReceipt(receipt);
|
|
412
|
+
if (!provider.delete)
|
|
413
|
+
throw new UnsupportedCapabilityError(provider.id, "deletion");
|
|
414
|
+
await provider.delete(receipt, options);
|
|
415
|
+
await this.#hooks?.onDeleteComplete?.({ receipt });
|
|
416
|
+
}
|
|
417
|
+
async deleteMany(receipts) {
|
|
418
|
+
return Promise.allSettled(receipts.map((receipt) => this.delete(receipt)));
|
|
419
|
+
}
|
|
420
|
+
#provider(provider) {
|
|
421
|
+
const selected = this.#providers[provider];
|
|
422
|
+
if (!selected) {
|
|
423
|
+
throw new StorageError("PROVIDER_NOT_FOUND", `Storage provider '${provider}' is not configured`, {
|
|
424
|
+
status: 400
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return selected;
|
|
428
|
+
}
|
|
429
|
+
#providerForReceipt(receipt) {
|
|
430
|
+
const provider = this.#providers[receipt.provider];
|
|
431
|
+
if (!provider) {
|
|
432
|
+
throw new StorageError("PROVIDER_NOT_FOUND", `Storage provider '${receipt.provider}' is not configured`, { status: 400 });
|
|
433
|
+
}
|
|
434
|
+
if (provider.id !== receipt.provider) {
|
|
435
|
+
throw new StorageError("INVALID_RECEIPT", "Receipt provider does not match configuration");
|
|
436
|
+
}
|
|
437
|
+
return provider;
|
|
438
|
+
}
|
|
439
|
+
async#preparePreview(source, options) {
|
|
440
|
+
if (options.preview === false || options.previewGenerator === false)
|
|
441
|
+
return;
|
|
442
|
+
if (options.preview)
|
|
443
|
+
return options.preview;
|
|
444
|
+
const candidates = options.previewGenerator ? this.#previewGenerators.filter((generator) => generator.id === options.previewGenerator) : this.#previewGenerators;
|
|
445
|
+
for (const generator of candidates) {
|
|
446
|
+
if (await generator.supports(source)) {
|
|
447
|
+
return generator.generate(source, {
|
|
448
|
+
signal: options.signal,
|
|
449
|
+
onProgress: options.onPreviewProgress
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (options.previewGenerator && candidates.length === 0) {
|
|
454
|
+
throw new StorageError("INVALID_INPUT", `Preview generator '${options.previewGenerator}' is not configured`);
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function createStorage(options) {
|
|
460
|
+
return new Storage(options);
|
|
461
|
+
}
|
|
462
|
+
function positiveInteger(value, name) {
|
|
463
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
464
|
+
throw new StorageError("INVALID_INPUT", `${name} must be a positive integer`);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
}
|
|
468
|
+
export {
|
|
469
|
+
validateUploadSource,
|
|
470
|
+
streamParts,
|
|
471
|
+
storageReceiptSchema,
|
|
472
|
+
serializeReceipt,
|
|
473
|
+
providerCapabilities,
|
|
474
|
+
parseReceipt,
|
|
475
|
+
parseByteRange,
|
|
476
|
+
normalizeUploadSource,
|
|
477
|
+
defineStorageProvider,
|
|
478
|
+
createStorage,
|
|
479
|
+
UnsupportedCapabilityError,
|
|
480
|
+
StorageError,
|
|
481
|
+
Storage,
|
|
482
|
+
RangeNotSatisfiableError,
|
|
483
|
+
MemoryUploadJournal
|
|
484
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ProviderMultipartPart, StoredFile } from "./types";
|
|
2
|
+
export type UploadSessionStatus = "active" | "completing" | "complete" | "aborted";
|
|
3
|
+
export interface UploadSession<State = unknown, Locator = unknown> {
|
|
4
|
+
id: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
route: string;
|
|
7
|
+
file: Omit<StoredFile, "sha256"> & {
|
|
8
|
+
sha256?: string;
|
|
9
|
+
};
|
|
10
|
+
providerState: State;
|
|
11
|
+
partSize: number;
|
|
12
|
+
parts: Record<string, ProviderMultipartPart<Locator>>;
|
|
13
|
+
status: UploadSessionStatus;
|
|
14
|
+
result?: unknown;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface UploadJournal {
|
|
19
|
+
create(session: UploadSession): Promise<void>;
|
|
20
|
+
get(id: string): Promise<UploadSession | undefined>;
|
|
21
|
+
putPart(id: string, part: ProviderMultipartPart): Promise<UploadSession>;
|
|
22
|
+
setStatus(id: string, status: UploadSessionStatus): Promise<UploadSession>;
|
|
23
|
+
setResult(id: string, result: unknown): Promise<UploadSession>;
|
|
24
|
+
remove(id: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare class MemoryUploadJournal implements UploadJournal {
|
|
27
|
+
#private;
|
|
28
|
+
create(session: UploadSession): Promise<void>;
|
|
29
|
+
get(id: string): Promise<UploadSession | undefined>;
|
|
30
|
+
putPart(id: string, part: ProviderMultipartPart): Promise<UploadSession>;
|
|
31
|
+
setStatus(id: string, status: UploadSessionStatus): Promise<UploadSession>;
|
|
32
|
+
setResult(id: string, result: unknown): Promise<UploadSession>;
|
|
33
|
+
remove(id: string): Promise<void>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ProviderCapabilities, StorageProvider } from "./types";
|
|
2
|
+
/** Build a complete capability declaration without forcing small providers to repeat false flags. */
|
|
3
|
+
export declare function providerCapabilities(capabilities: Partial<ProviderCapabilities>): ProviderCapabilities;
|
|
4
|
+
/** Identity helper that preserves a provider's receipt, multipart-state, and locator inference. */
|
|
5
|
+
export declare function defineStorageProvider<const Provider extends StorageProvider<any, any, any>>(provider: Provider): Provider;
|
package/dist/range.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { StorageReceipt } from "./types";
|
|
3
|
+
export declare const storageReceiptSchema: z.ZodObject<{
|
|
4
|
+
version: z.ZodLiteral<1>;
|
|
5
|
+
provider: z.ZodString;
|
|
6
|
+
connectionId: z.ZodString;
|
|
7
|
+
createdAt: z.ZodString;
|
|
8
|
+
file: z.ZodObject<{
|
|
9
|
+
name: z.ZodString;
|
|
10
|
+
contentType: z.ZodString;
|
|
11
|
+
size: z.ZodNumber;
|
|
12
|
+
sha256: z.ZodString;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
locator: z.ZodUnknown;
|
|
15
|
+
preview: z.ZodOptional<z.ZodObject<{
|
|
16
|
+
contentType: z.ZodString;
|
|
17
|
+
size: z.ZodNumber;
|
|
18
|
+
width: z.ZodNumber;
|
|
19
|
+
height: z.ZodNumber;
|
|
20
|
+
}, z.core.$strip>>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export declare function parseReceipt(value: unknown): StorageReceipt;
|
|
23
|
+
export declare function serializeReceipt(receipt: StorageReceipt): string;
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { StorageUploadInput, StorageUploadSource } from "./types";
|
|
2
|
+
export declare function normalizeUploadSource(input: StorageUploadInput): StorageUploadSource;
|
|
3
|
+
export declare function validateUploadSource(source: StorageUploadSource, maxFileSize?: number): void;
|
|
4
|
+
export declare function streamParts(body: ReadableStream<Uint8Array>, expectedSize: number, partSize: number): AsyncGenerator<Uint8Array>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GeneratedPreview, PreviewGenerator, ProviderMap, ReceiptForMap, ReceiptForProvider, StorageHooks, StorageOpenOptions, StoragePreviewOptions, StorageProvider, StorageUploadInput } from "./types";
|
|
2
|
+
export interface CreateStorageOptions<Providers extends ProviderMap> {
|
|
3
|
+
providers: Providers;
|
|
4
|
+
defaultProvider?: keyof Providers & string;
|
|
5
|
+
previewGenerators?: readonly PreviewGenerator[];
|
|
6
|
+
hooks?: StorageHooks<ReceiptForMap<Providers>>;
|
|
7
|
+
}
|
|
8
|
+
export interface UploadOptions<Provider extends string = string> {
|
|
9
|
+
provider?: Provider;
|
|
10
|
+
preview?: GeneratedPreview | false;
|
|
11
|
+
previewGenerator?: string | false;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
concurrency?: number;
|
|
14
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
15
|
+
onProgress?: Parameters<StorageProvider["upload"]>[1]["onProgress"];
|
|
16
|
+
onPreviewProgress?: (event: {
|
|
17
|
+
stage: string;
|
|
18
|
+
percentage: number;
|
|
19
|
+
}) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface UploadFromUrlOptions<Provider extends string = string> extends UploadOptions<Provider> {
|
|
22
|
+
name?: string;
|
|
23
|
+
contentType?: string;
|
|
24
|
+
fetch?: typeof globalThis.fetch;
|
|
25
|
+
request?: RequestInit;
|
|
26
|
+
}
|
|
27
|
+
export declare class Storage<Providers extends ProviderMap> {
|
|
28
|
+
#private;
|
|
29
|
+
constructor(options: CreateStorageOptions<Providers>);
|
|
30
|
+
capabilities<Provider extends keyof Providers & string>(provider: Provider): import("./types").ProviderCapabilities;
|
|
31
|
+
provider<Provider extends keyof Providers & string>(provider: Provider): Providers[Provider];
|
|
32
|
+
upload<Provider extends keyof Providers & string>(input: StorageUploadInput, options?: UploadOptions<Provider>): Promise<ReceiptForProvider<Providers[Provider]>>;
|
|
33
|
+
uploadMany<Provider extends keyof Providers & string>(inputs: readonly StorageUploadInput[], options?: UploadOptions<Provider> & {
|
|
34
|
+
fileConcurrency?: number;
|
|
35
|
+
}): Promise<Array<ReceiptForProvider<Providers[Provider]>>>;
|
|
36
|
+
uploadFromUrl<Provider extends keyof Providers & string>(value: string | URL, options?: UploadFromUrlOptions<Provider>): Promise<ReceiptForProvider<Providers[Provider]>>;
|
|
37
|
+
stat(receiptValue: unknown): Promise<import("./types").StoredFile & {
|
|
38
|
+
preview?: import("./types").StoredPreview;
|
|
39
|
+
}>;
|
|
40
|
+
verify(receiptValue: unknown): Promise<true>;
|
|
41
|
+
open(receiptValue: unknown, options?: StorageOpenOptions): Promise<Response>;
|
|
42
|
+
toResponse(receiptValue: unknown, request: Request): Promise<Response>;
|
|
43
|
+
preview(receiptValue: unknown, options?: StoragePreviewOptions): Promise<Response>;
|
|
44
|
+
refresh(receiptValue: unknown): Promise<import("./types").RefreshedProviderLinks>;
|
|
45
|
+
delete(receiptValue: unknown, options?: {
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
deleteMany(receipts: readonly unknown[]): Promise<PromiseSettledResult<void>[]>;
|
|
49
|
+
}
|
|
50
|
+
export declare function createStorage<const Providers extends ProviderMap>(options: CreateStorageOptions<Providers>): Storage<Providers>;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type MaybePromise<T> = T | Promise<T>;
|
|
2
|
+
export interface StoredFile {
|
|
3
|
+
name: string;
|
|
4
|
+
contentType: string;
|
|
5
|
+
size: number;
|
|
6
|
+
sha256: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StoredPreview {
|
|
9
|
+
contentType: string;
|
|
10
|
+
size: number;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
export interface StorageReceipt<Provider extends string = string, Locator = unknown> {
|
|
15
|
+
version: 1;
|
|
16
|
+
provider: Provider;
|
|
17
|
+
connectionId: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
file: StoredFile;
|
|
20
|
+
locator: Locator;
|
|
21
|
+
preview?: StoredPreview;
|
|
22
|
+
}
|
|
23
|
+
export interface StorageUploadSource {
|
|
24
|
+
name: string;
|
|
25
|
+
contentType: string;
|
|
26
|
+
size: number;
|
|
27
|
+
stream(): ReadableStream<Uint8Array>;
|
|
28
|
+
/** Present when the original input is Blob-compatible, such as a browser File. */
|
|
29
|
+
blob?: Blob;
|
|
30
|
+
}
|
|
31
|
+
export type StorageUploadInput = File | {
|
|
32
|
+
name: string;
|
|
33
|
+
contentType?: string;
|
|
34
|
+
size: number;
|
|
35
|
+
body: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
|
|
36
|
+
};
|
|
37
|
+
export interface GeneratedPreview {
|
|
38
|
+
file: File;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
42
|
+
}
|
|
43
|
+
export interface PreviewGenerationContext {
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
onProgress?: (event: {
|
|
46
|
+
stage: string;
|
|
47
|
+
percentage: number;
|
|
48
|
+
}) => void;
|
|
49
|
+
}
|
|
50
|
+
export interface PreviewGenerator {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
supports(source: StorageUploadSource): MaybePromise<boolean>;
|
|
53
|
+
generate(source: StorageUploadSource, context: PreviewGenerationContext): Promise<GeneratedPreview | undefined>;
|
|
54
|
+
}
|
|
55
|
+
export interface ProviderCapabilities {
|
|
56
|
+
readonly upload: boolean;
|
|
57
|
+
readonly uploadStream: boolean;
|
|
58
|
+
readonly multipartUpload: boolean;
|
|
59
|
+
readonly parallelUpload: boolean;
|
|
60
|
+
readonly resumableUpload: boolean;
|
|
61
|
+
readonly read: boolean;
|
|
62
|
+
readonly rangeRead: boolean;
|
|
63
|
+
readonly delete: boolean;
|
|
64
|
+
readonly preview: boolean;
|
|
65
|
+
readonly nativePreview: boolean;
|
|
66
|
+
readonly imageTransform: "none" | "experimental" | "supported";
|
|
67
|
+
readonly directClientUpload: boolean;
|
|
68
|
+
readonly maxFileSize?: number;
|
|
69
|
+
readonly preferredPartSize?: number;
|
|
70
|
+
readonly maxPartSize?: number;
|
|
71
|
+
readonly maxPartsPerRequest?: number;
|
|
72
|
+
}
|
|
73
|
+
export interface ProviderUploadOptions {
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
preview?: GeneratedPreview;
|
|
76
|
+
concurrency?: number;
|
|
77
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
78
|
+
onProgress?: (event: StorageProgressEvent) => void;
|
|
79
|
+
}
|
|
80
|
+
export interface StorageProgressEvent {
|
|
81
|
+
phase: "preparing" | "uploading" | "finalizing";
|
|
82
|
+
uploadedBytes: number;
|
|
83
|
+
totalBytes: number;
|
|
84
|
+
completedParts?: number;
|
|
85
|
+
totalParts?: number;
|
|
86
|
+
}
|
|
87
|
+
export interface StorageOpenOptions {
|
|
88
|
+
method?: "GET" | "HEAD";
|
|
89
|
+
range?: string | null;
|
|
90
|
+
download?: boolean;
|
|
91
|
+
signal?: AbortSignal;
|
|
92
|
+
}
|
|
93
|
+
export interface StoragePreviewOptions {
|
|
94
|
+
width?: number;
|
|
95
|
+
height?: number;
|
|
96
|
+
format?: "avif" | "jpeg" | "jpg" | "png" | "webp";
|
|
97
|
+
quality?: "low" | "high" | "lossless";
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}
|
|
100
|
+
export interface RefreshedProviderLinks {
|
|
101
|
+
expires: boolean;
|
|
102
|
+
parts: string[];
|
|
103
|
+
preview?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface ProviderMultipartSession<State = unknown> {
|
|
106
|
+
state: State;
|
|
107
|
+
partSize: number;
|
|
108
|
+
maxConcurrency?: number;
|
|
109
|
+
}
|
|
110
|
+
export interface ProviderMultipartPart<Locator = unknown> {
|
|
111
|
+
index: number;
|
|
112
|
+
size: number;
|
|
113
|
+
sha256: string;
|
|
114
|
+
locator: Locator;
|
|
115
|
+
}
|
|
116
|
+
export interface StorageProvider<Receipt extends StorageReceipt = StorageReceipt, MultipartState = unknown, MultipartLocator = unknown> {
|
|
117
|
+
readonly id: Receipt["provider"];
|
|
118
|
+
readonly capabilities: ProviderCapabilities;
|
|
119
|
+
upload(source: StorageUploadSource, options: ProviderUploadOptions): Promise<Receipt>;
|
|
120
|
+
stat(receipt: Receipt): Promise<StoredFile & {
|
|
121
|
+
preview?: StoredPreview;
|
|
122
|
+
}>;
|
|
123
|
+
open?(receipt: Receipt, options: StorageOpenOptions): Promise<Response>;
|
|
124
|
+
delete?(receipt: Receipt, options?: {
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
}): Promise<void>;
|
|
127
|
+
preview?(receipt: Receipt, options: StoragePreviewOptions): Promise<Response>;
|
|
128
|
+
refresh?(receipt: Receipt): Promise<RefreshedProviderLinks>;
|
|
129
|
+
beginMultipart?(file: Omit<StoredFile, "sha256">, options: ProviderUploadOptions): Promise<ProviderMultipartSession<MultipartState>>;
|
|
130
|
+
uploadPart?(session: ProviderMultipartSession<MultipartState>, part: {
|
|
131
|
+
index: number;
|
|
132
|
+
bytes: Uint8Array;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}): Promise<ProviderMultipartPart<MultipartLocator>>;
|
|
135
|
+
completeMultipart?(session: ProviderMultipartSession<MultipartState>, parts: ProviderMultipartPart<MultipartLocator>[], file: StoredFile, options: ProviderUploadOptions): Promise<Receipt>;
|
|
136
|
+
abortMultipart?(session: ProviderMultipartSession<MultipartState>, parts: ProviderMultipartPart<MultipartLocator>[]): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
export type ProviderMap = Readonly<Record<string, StorageProvider<StorageReceipt, any, any>>>;
|
|
139
|
+
export type ReceiptForProvider<Provider> = Provider extends StorageProvider<infer Receipt, any, any> ? Receipt : never;
|
|
140
|
+
export type ReceiptForMap<Providers extends ProviderMap> = ReceiptForProvider<Providers[keyof Providers]>;
|
|
141
|
+
export interface StorageHooks<Receipt extends StorageReceipt = StorageReceipt> {
|
|
142
|
+
onUploadComplete?(context: {
|
|
143
|
+
receipt: Receipt;
|
|
144
|
+
metadata?: Readonly<Record<string, unknown>>;
|
|
145
|
+
}): MaybePromise<unknown>;
|
|
146
|
+
onUploadError?(context: {
|
|
147
|
+
error: unknown;
|
|
148
|
+
provider: string;
|
|
149
|
+
source: StorageUploadSource;
|
|
150
|
+
}): MaybePromise<void>;
|
|
151
|
+
onDeleteComplete?(context: {
|
|
152
|
+
receipt: Receipt;
|
|
153
|
+
}): MaybePromise<void>;
|
|
154
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@minhvuong/pirate-storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "bun build src/index.ts --outdir dist --target browser --packages external && tsc -p tsconfig.build.json",
|
|
21
|
+
"check-types": "tsc --noEmit",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"zod": "catalog:"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "^1.3.10",
|
|
29
|
+
"typescript": "catalog:"
|
|
30
|
+
}
|
|
31
|
+
}
|