@minhvuong/pirate-storage-server 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 ADDED
@@ -0,0 +1,19 @@
1
+ # @minhvuong/pirate-storage-server
2
+
3
+ Fetch-standard route builder and authenticated upload handler for `@minhvuong/pirate-storage`.
4
+
5
+ The handler supports direct streamed uploads and resumable multipart sessions. Multipart parts are
6
+ normal authenticated requests to the consumer's server or Worker; provider secrets are never sent
7
+ to the browser.
8
+
9
+ Applications provide the journal implementation and persist completed receipts in
10
+ `onUploadComplete` using any database or ORM.
11
+
12
+ The journal stores temporary coordination metadata (file identity, provider session state,
13
+ completed part locators/hashes, status, and expiry), never file bytes. During cancellation the
14
+ handler marks the session aborted, waits for in-flight uploads, deletes late provider parts, and
15
+ only then removes the journal record.
16
+
17
+ Concurrent requests for the same session part share one provider operation. This prevents a quick
18
+ pause/resume from creating duplicate Discord attachment messages while the first request is still
19
+ finishing server-side.
@@ -0,0 +1,15 @@
1
+ import { Storage, type UploadJournal } from "@minhvuong/pirate-storage";
2
+ import type { FileRouter } from "./router";
3
+ export interface FetchHandlerOptions<Router extends FileRouter> {
4
+ storage: Storage<any>;
5
+ router: Router;
6
+ journal: UploadJournal;
7
+ basePath?: string;
8
+ sessionTtlMs?: number;
9
+ corsOrigin?: string | ((request: Request) => string | undefined);
10
+ }
11
+ export interface StorageFetchHandler {
12
+ (request: Request): Promise<Response>;
13
+ fetch(request: Request): Promise<Response>;
14
+ }
15
+ export declare function createFetchHandler<const Router extends FileRouter>(options: FetchHandlerOptions<Router>): StorageFetchHandler;
@@ -0,0 +1,2 @@
1
+ export * from "./handler";
2
+ export * from "./router";
package/dist/index.js ADDED
@@ -0,0 +1,485 @@
1
+ // src/handler.ts
2
+ import {
3
+ StorageError
4
+ } from "@minhvuong/pirate-storage";
5
+ function createFetchHandler(options) {
6
+ const basePath = normalizeBasePath(options.basePath ?? "/api/storage");
7
+ const sessionTtlMs = options.sessionTtlMs ?? 24 * 60 * 60 * 1000;
8
+ const inFlightParts = new Map;
9
+ const inFlightPartRequests = new Map;
10
+ const handle = async (request) => {
11
+ try {
12
+ if (request.method === "OPTIONS")
13
+ return withCors(new Response(null, { status: 204 }), request, options);
14
+ const url = new URL(request.url);
15
+ if (!url.pathname.startsWith(`${basePath}/`))
16
+ return json({ error: "Not found" }, 404);
17
+ const segments = url.pathname.slice(basePath.length + 1).split("/").map(decodeURIComponent);
18
+ const routeName = segments[0];
19
+ const route = routeName ? options.router[routeName] : undefined;
20
+ if (!route || !routeName)
21
+ return json({ error: "Storage route not found" }, 404);
22
+ let response;
23
+ if (segments.length === 1 && request.method === "POST") {
24
+ response = await directUpload(request, routeName, route, options.storage);
25
+ } else if (segments[1] === "multipart" && segments.length === 2 && request.method === "POST") {
26
+ response = await beginMultipart(request, routeName, route, options, sessionTtlMs);
27
+ } else if (segments[1] === "multipart" && segments[2] && segments[3] && segments.length === 4 && request.method === "PUT") {
28
+ response = await uploadMultipartPart(request, routeName, route, segments[2], segments[3], options, inFlightParts, inFlightPartRequests);
29
+ } else if (segments[1] === "multipart" && segments[2] && segments[3] === "complete" && request.method === "POST") {
30
+ response = await completeMultipart(request, routeName, route, segments[2], options);
31
+ } else if (segments[1] === "multipart" && segments[2] && segments.length === 3 && request.method === "GET") {
32
+ response = await inspectMultipart(request, routeName, route, segments[2], options);
33
+ } else if (segments[1] === "multipart" && segments[2] && segments.length === 3 && request.method === "DELETE") {
34
+ response = await abortMultipart(request, routeName, route, segments[2], options, inFlightParts);
35
+ } else {
36
+ response = json({ error: "Method not allowed" }, 405);
37
+ }
38
+ return withCors(response, request, options);
39
+ } catch (error) {
40
+ const status = error instanceof StorageError ? error.status ?? 500 : 500;
41
+ return withCors(json({
42
+ error: error instanceof Error ? error.message : "Unknown storage server error",
43
+ code: error instanceof StorageError ? error.code : "INTERNAL_ERROR"
44
+ }, status), request, options);
45
+ }
46
+ };
47
+ const handler = handle;
48
+ handler.fetch = handle;
49
+ return handler;
50
+ }
51
+ async function directUpload(request, routeName, route, storage) {
52
+ if (!request.body)
53
+ throw new StorageError("INVALID_INPUT", "A binary request body is required", { status: 400 });
54
+ const name = decodedHeader(request, "x-storage-file-name");
55
+ const size = positiveHeader(request, "x-storage-file-size");
56
+ const input = await parseRouteInput(request, route);
57
+ const metadata = await route.authorize({ request, input, route: routeName });
58
+ validateRouteFile(route, name, request.headers.get("content-type") ?? "application/octet-stream", size);
59
+ try {
60
+ const receipt = await storage.upload({
61
+ name,
62
+ size,
63
+ contentType: request.headers.get("content-type") ?? "application/octet-stream",
64
+ body: request.body
65
+ }, {
66
+ provider: route.config.provider,
67
+ metadata: asMetadata(metadata),
68
+ signal: request.signal
69
+ });
70
+ const serverData = await route.onComplete?.({
71
+ request,
72
+ input,
73
+ metadata,
74
+ receipt,
75
+ route: routeName
76
+ });
77
+ return json({ receipt, serverData }, 201);
78
+ } catch (error) {
79
+ await route.onError?.({ request, route: routeName, error });
80
+ throw error;
81
+ }
82
+ }
83
+ async function beginMultipart(request, routeName, route, options, sessionTtlMs) {
84
+ const body = await request.json();
85
+ const file = parseFileDescriptor(body.file);
86
+ const fileSha256 = parseSha256(body.file?.sha256, "file.sha256");
87
+ validateRouteFile(route, file.name, file.contentType, file.size);
88
+ const input = await route.parseInput(body.input);
89
+ const metadata = await route.authorize({ request, input, route: routeName });
90
+ const provider = getMultipartProvider(options.storage, route.config.provider);
91
+ const providerSession = await provider.beginMultipart(file, {
92
+ signal: request.signal,
93
+ metadata: asMetadata(metadata)
94
+ });
95
+ const id = crypto.randomUUID();
96
+ const now = Date.now();
97
+ const session = {
98
+ id,
99
+ provider: route.config.provider,
100
+ route: routeName,
101
+ file: { ...file, sha256: fileSha256 },
102
+ providerState: providerSession.state,
103
+ partSize: providerSession.partSize,
104
+ parts: {},
105
+ status: "active",
106
+ createdAt: new Date(now).toISOString(),
107
+ expiresAt: new Date(now + sessionTtlMs).toISOString()
108
+ };
109
+ await options.journal.create(session);
110
+ return json({
111
+ sessionId: id,
112
+ partSize: session.partSize,
113
+ maxConcurrency: providerSession.maxConcurrency,
114
+ completedParts: [],
115
+ expiresAt: session.expiresAt
116
+ }, 201);
117
+ }
118
+ async function inspectMultipart(request, routeName, route, sessionId, options) {
119
+ await authorizeExistingSession(request, routeName, route);
120
+ const session = await requiredSession(options.journal, sessionId, routeName);
121
+ assertSessionFileHash(request, session);
122
+ return json({
123
+ sessionId,
124
+ status: session.status,
125
+ partSize: session.partSize,
126
+ completedParts: Object.values(session.parts).map((part) => part.index).toSorted((a, b) => a - b),
127
+ expiresAt: session.expiresAt,
128
+ result: session.result
129
+ });
130
+ }
131
+ async function uploadMultipartPart(request, routeName, route, sessionId, rawIndex, options, inFlightParts, inFlightPartRequests) {
132
+ await authorizeExistingSession(request, routeName, route);
133
+ const session = await requiredSession(options.journal, sessionId, routeName);
134
+ if (session.status !== "active") {
135
+ throw new StorageError("INVALID_INPUT", `Upload session is ${session.status}`, { status: 409 });
136
+ }
137
+ const index = Number(rawIndex);
138
+ if (!Number.isSafeInteger(index) || index < 0) {
139
+ throw new StorageError("INVALID_INPUT", "Part index must be a non-negative integer", {
140
+ status: 400
141
+ });
142
+ }
143
+ const existing = session.parts[String(index)];
144
+ if (existing)
145
+ return json({ part: existing, reused: true });
146
+ const operationKey = `${sessionId}:${index}`;
147
+ const existingOperation = inFlightPartRequests.get(operationKey);
148
+ if (existingOperation) {
149
+ return json({ part: await existingOperation, reused: true });
150
+ }
151
+ const provider = getMultipartProvider(options.storage, session.provider);
152
+ const operation = (async () => {
153
+ const bytes = new Uint8Array(await request.arrayBuffer());
154
+ if (bytes.byteLength > session.partSize) {
155
+ throw new StorageError("FILE_TOO_LARGE", "Multipart request exceeds the session part size", {
156
+ status: 413
157
+ });
158
+ }
159
+ const part2 = await provider.uploadPart(providerSession(session), {
160
+ index,
161
+ bytes
162
+ });
163
+ try {
164
+ await options.journal.putPart(sessionId, part2);
165
+ } catch (error) {
166
+ await provider.abortMultipart?.(providerSession(session), [part2]);
167
+ throw error;
168
+ }
169
+ return part2;
170
+ })();
171
+ inFlightPartRequests.set(operationKey, operation);
172
+ trackInFlight(inFlightParts, sessionId, operation);
173
+ operation.finally(() => {
174
+ if (inFlightPartRequests.get(operationKey) === operation) {
175
+ inFlightPartRequests.delete(operationKey);
176
+ }
177
+ }).catch(() => {
178
+ return;
179
+ });
180
+ const part = await operation;
181
+ return json({ part, reused: false }, 201);
182
+ }
183
+ async function completeMultipart(request, routeName, route, sessionId, options) {
184
+ const { input, metadata } = await authorizeExistingSession(request, routeName, route);
185
+ let session = await requiredSession(options.journal, sessionId, routeName);
186
+ if (session.status === "complete")
187
+ return json(session.result);
188
+ if (session.status !== "active") {
189
+ throw new StorageError("INVALID_INPUT", `Upload session is ${session.status}`, { status: 409 });
190
+ }
191
+ const sha256 = requiredHeader(request, "x-storage-file-sha256");
192
+ if (!/^[a-f0-9]{64}$/.test(sha256)) {
193
+ throw new StorageError("INVALID_INPUT", "x-storage-file-sha256 must be a SHA-256 hex digest", {
194
+ status: 400
195
+ });
196
+ }
197
+ if (session.file.sha256 && sha256 !== session.file.sha256) {
198
+ throw new StorageError("INTEGRITY_FAILED", "The selected file does not match the file that started this upload session", { status: 409 });
199
+ }
200
+ const expectedParts = Math.ceil(session.file.size / session.partSize);
201
+ const parts = Object.values(session.parts).toSorted((left, right) => left.index - right.index);
202
+ if (parts.length !== expectedParts) {
203
+ throw new StorageError("INVALID_INPUT", `Upload is missing ${expectedParts - parts.length} parts`, { status: 409 });
204
+ }
205
+ const preview = await parseOptionalPreview(request, session.partSize);
206
+ session = await options.journal.setStatus(sessionId, "completing");
207
+ const provider = getMultipartProvider(options.storage, session.provider);
208
+ try {
209
+ const receipt = await provider.completeMultipart(providerSession(session), parts, {
210
+ name: session.file.name,
211
+ contentType: session.file.contentType,
212
+ size: session.file.size,
213
+ sha256
214
+ }, { signal: request.signal, preview, metadata: asMetadata(metadata) });
215
+ const serverData = await route.onComplete?.({
216
+ request,
217
+ input,
218
+ metadata,
219
+ receipt,
220
+ route: routeName
221
+ });
222
+ const result = { receipt, serverData };
223
+ await options.journal.setResult(sessionId, result);
224
+ return json(result, 201);
225
+ } catch (error) {
226
+ await options.journal.setStatus(sessionId, "active").catch(() => {
227
+ return;
228
+ });
229
+ await route.onError?.({ request, route: routeName, error });
230
+ throw error;
231
+ }
232
+ }
233
+ async function abortMultipart(request, routeName, route, sessionId, options, inFlightParts) {
234
+ await authorizeExistingSession(request, routeName, route);
235
+ let session = await requiredSession(options.journal, sessionId, routeName);
236
+ if (session.status === "complete") {
237
+ throw new StorageError("INVALID_INPUT", "A completed upload cannot be aborted", { status: 409 });
238
+ }
239
+ if (session.status !== "aborted") {
240
+ session = await options.journal.setStatus(sessionId, "aborted");
241
+ }
242
+ await Promise.allSettled(inFlightParts.get(sessionId) ?? []);
243
+ session = await options.journal.get(sessionId) ?? session;
244
+ const provider = getMultipartProvider(options.storage, session.provider);
245
+ await provider.abortMultipart?.(providerSession(session), Object.values(session.parts));
246
+ await options.journal.remove(sessionId);
247
+ return new Response(null, { status: 204 });
248
+ }
249
+ function trackInFlight(registry, sessionId, operation) {
250
+ const operations = registry.get(sessionId) ?? new Set;
251
+ operations.add(operation);
252
+ registry.set(sessionId, operations);
253
+ operation.finally(() => {
254
+ operations.delete(operation);
255
+ if (operations.size === 0)
256
+ registry.delete(sessionId);
257
+ }).catch(() => {
258
+ return;
259
+ });
260
+ }
261
+ function assertSessionFileHash(request, session) {
262
+ const supplied = requiredHeader(request, "x-storage-file-sha256");
263
+ if (!/^[a-f0-9]{64}$/.test(supplied)) {
264
+ throw new StorageError("INVALID_INPUT", "x-storage-file-sha256 must be a SHA-256 hex digest", {
265
+ status: 400
266
+ });
267
+ }
268
+ if (!session.file.sha256) {
269
+ throw new StorageError("INTEGRITY_FAILED", "This upload session predates safe resume and must be restarted", { status: 409 });
270
+ }
271
+ if (supplied !== session.file.sha256) {
272
+ throw new StorageError("INTEGRITY_FAILED", "The selected file does not match the file that started this upload session", { status: 409 });
273
+ }
274
+ }
275
+ function parseSha256(value, name) {
276
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
277
+ throw new StorageError("INVALID_INPUT", `${name} must be a SHA-256 hex digest`, {
278
+ status: 400
279
+ });
280
+ }
281
+ return value;
282
+ }
283
+ function getMultipartProvider(storage, name) {
284
+ const provider = storage.provider(name);
285
+ if (!provider.beginMultipart || !provider.uploadPart || !provider.completeMultipart) {
286
+ throw new StorageError("CAPABILITY_UNSUPPORTED", `Provider '${name}' does not support multipart uploads`, { status: 501 });
287
+ }
288
+ return provider;
289
+ }
290
+ function providerSession(session) {
291
+ return { state: session.providerState, partSize: session.partSize };
292
+ }
293
+ async function authorizeExistingSession(request, routeName, route) {
294
+ const input = await parseRouteInput(request, route);
295
+ const metadata = await route.authorize({ request, input, route: routeName });
296
+ return { input, metadata };
297
+ }
298
+ async function parseRouteInput(request, route) {
299
+ const encoded = request.headers.get("x-storage-input");
300
+ if (!encoded)
301
+ return route.parseInput(undefined);
302
+ try {
303
+ return route.parseInput(JSON.parse(decodeURIComponent(encoded)));
304
+ } catch (error) {
305
+ throw new StorageError("INVALID_INPUT", "x-storage-input must contain valid JSON", {
306
+ status: 400,
307
+ cause: error
308
+ });
309
+ }
310
+ }
311
+ async function parseOptionalPreview(request, maxSize) {
312
+ const widthValue = request.headers.get("x-storage-preview-width");
313
+ const heightValue = request.headers.get("x-storage-preview-height");
314
+ if (!widthValue && !heightValue)
315
+ return;
316
+ if (!widthValue || !heightValue) {
317
+ throw new StorageError("INVALID_INPUT", "Preview width and height are required together", {
318
+ status: 400
319
+ });
320
+ }
321
+ const width = Number(widthValue);
322
+ const height = Number(heightValue);
323
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
324
+ throw new StorageError("INVALID_INPUT", "Preview dimensions must be positive integers", {
325
+ status: 400
326
+ });
327
+ }
328
+ const bytes = await request.arrayBuffer();
329
+ if (bytes.byteLength <= 0 || bytes.byteLength > maxSize) {
330
+ throw new StorageError("FILE_TOO_LARGE", "Preview exceeds the provider part size", {
331
+ status: 413
332
+ });
333
+ }
334
+ const contentType = request.headers.get("content-type") ?? "image/jpeg";
335
+ return {
336
+ file: new File([bytes], `${requiredHeader(request, "x-storage-file-name")}.preview`, {
337
+ type: contentType
338
+ }),
339
+ width,
340
+ height
341
+ };
342
+ }
343
+ function parseFileDescriptor(value) {
344
+ if (!value || typeof value !== "object") {
345
+ throw new StorageError("INVALID_INPUT", "A file descriptor is required", {
346
+ status: 400
347
+ });
348
+ }
349
+ const file = value;
350
+ if (typeof file.name !== "string" || !file.name || typeof file.size !== "number" || !Number.isSafeInteger(file.size) || file.size <= 0 || typeof file.contentType !== "string" || !file.contentType) {
351
+ throw new StorageError("INVALID_INPUT", "The file descriptor is invalid", {
352
+ status: 400
353
+ });
354
+ }
355
+ return { name: file.name, size: file.size, contentType: file.contentType };
356
+ }
357
+ function validateRouteFile(route, name, contentType, size) {
358
+ if (!name || name.length > 255 || size <= 0) {
359
+ throw new StorageError("INVALID_INPUT", "File name or size is invalid", {
360
+ status: 400
361
+ });
362
+ }
363
+ if (route.config.maxFileSize && size > route.config.maxFileSize) {
364
+ throw new StorageError("FILE_TOO_LARGE", "File exceeds the route limit", {
365
+ status: 413
366
+ });
367
+ }
368
+ if (route.config.accept?.length && !route.config.accept.some((pattern) => matchesContentType(contentType, pattern))) {
369
+ throw new StorageError("INVALID_INPUT", `Content type '${contentType}' is not accepted`, {
370
+ status: 415
371
+ });
372
+ }
373
+ }
374
+ function matchesContentType(contentType, pattern) {
375
+ return pattern.endsWith("/*") ? contentType.startsWith(pattern.slice(0, -1)) : contentType === pattern;
376
+ }
377
+ async function requiredSession(journal, id, route) {
378
+ const session = await journal.get(id);
379
+ if (!session || session.route !== route) {
380
+ throw new StorageError("SESSION_NOT_FOUND", "Upload session was not found", { status: 404 });
381
+ }
382
+ if (Date.parse(session.expiresAt) <= Date.now()) {
383
+ throw new StorageError("SESSION_EXPIRED", "Upload session has expired", {
384
+ status: 410
385
+ });
386
+ }
387
+ return session;
388
+ }
389
+ function requiredHeader(request, name) {
390
+ const value = request.headers.get(name);
391
+ if (!value)
392
+ throw new StorageError("INVALID_INPUT", `${name} header is required`, {
393
+ status: 400
394
+ });
395
+ return value;
396
+ }
397
+ function decodedHeader(request, name) {
398
+ const value = requiredHeader(request, name);
399
+ try {
400
+ return decodeURIComponent(value);
401
+ } catch (error) {
402
+ throw new StorageError("INVALID_INPUT", `${name} is not valid URI-encoded text`, {
403
+ status: 400,
404
+ cause: error
405
+ });
406
+ }
407
+ }
408
+ function positiveHeader(request, name) {
409
+ const value = Number(requiredHeader(request, name));
410
+ if (!Number.isSafeInteger(value) || value <= 0) {
411
+ throw new StorageError("INVALID_INPUT", `${name} must be a positive integer`, { status: 400 });
412
+ }
413
+ return value;
414
+ }
415
+ function asMetadata(value) {
416
+ return value && typeof value === "object" ? value : undefined;
417
+ }
418
+ function normalizeBasePath(value) {
419
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
420
+ return withSlash.replace(/\/$/, "");
421
+ }
422
+ function json(value, status = 200) {
423
+ return Response.json(value, { status });
424
+ }
425
+ function withCors(response, request, options) {
426
+ const origin = typeof options.corsOrigin === "function" ? options.corsOrigin(request) : options.corsOrigin;
427
+ if (!origin)
428
+ return response;
429
+ response.headers.set("Access-Control-Allow-Origin", origin);
430
+ response.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Demo-Fail-Count, X-Demo-Fail-Part, X-Storage-File-Name, X-Storage-File-Size, X-Storage-File-Sha256, X-Storage-Input, X-Storage-Preview-Width, X-Storage-Preview-Height");
431
+ response.headers.set("Access-Control-Allow-Methods", "DELETE, GET, OPTIONS, POST, PUT");
432
+ response.headers.append("Vary", "Origin");
433
+ return response;
434
+ }
435
+ // src/router.ts
436
+ class FileRouteBuilder {
437
+ #definition;
438
+ constructor(definition) {
439
+ this.#definition = definition;
440
+ }
441
+ input(parser) {
442
+ return new FileRouteBuilder({
443
+ ...this.#definition,
444
+ parseInput: parser,
445
+ authorize: this.#definition.authorize,
446
+ onComplete: this.#definition.onComplete
447
+ });
448
+ }
449
+ middleware(authorize) {
450
+ return new FileRouteBuilder({
451
+ ...this.#definition,
452
+ authorize,
453
+ onComplete: this.#definition.onComplete
454
+ });
455
+ }
456
+ onUploadComplete(callback) {
457
+ return new FileRouteBuilder({ ...this.#definition, onComplete: callback });
458
+ }
459
+ onUploadError(callback) {
460
+ return new FileRouteBuilder({ ...this.#definition, onError: callback });
461
+ }
462
+ build() {
463
+ return this.#definition;
464
+ }
465
+ }
466
+ function createFileRoute() {
467
+ return (config) => new FileRouteBuilder({
468
+ config,
469
+ parseInput: (value) => value,
470
+ authorize: () => {
471
+ return;
472
+ }
473
+ });
474
+ }
475
+ function defineFileRouter(router) {
476
+ return Object.fromEntries(Object.entries(router).map(([key, value]) => [
477
+ key,
478
+ value instanceof FileRouteBuilder ? value.build() : value
479
+ ]));
480
+ }
481
+ export {
482
+ defineFileRouter,
483
+ createFileRoute,
484
+ createFetchHandler
485
+ };
@@ -0,0 +1,45 @@
1
+ import type { MaybePromise, StorageReceipt } from "@minhvuong/pirate-storage";
2
+ export interface FileRouteConfig<Provider extends string = string> {
3
+ provider: Provider;
4
+ accept?: readonly string[];
5
+ maxFileSize?: number;
6
+ maxFileCount?: number;
7
+ }
8
+ export interface FileRouteContext<Input = unknown> {
9
+ request: Request;
10
+ input: Input;
11
+ route: string;
12
+ }
13
+ export interface UploadCompleteContext<Metadata = unknown, Input = unknown> {
14
+ request: Request;
15
+ input: Input;
16
+ metadata: Metadata;
17
+ receipt: StorageReceipt;
18
+ route: string;
19
+ }
20
+ export interface FileRouteDefinition<Provider extends string = string, Input = unknown, Metadata = unknown, ServerData = unknown> {
21
+ readonly config: FileRouteConfig<Provider>;
22
+ readonly parseInput: (value: unknown) => MaybePromise<Input>;
23
+ readonly authorize: (context: FileRouteContext<Input>) => MaybePromise<Metadata>;
24
+ readonly onComplete?: (context: UploadCompleteContext<Metadata, Input>) => MaybePromise<ServerData>;
25
+ readonly onError?: (context: {
26
+ request: Request;
27
+ route: string;
28
+ error: unknown;
29
+ }) => MaybePromise<void>;
30
+ }
31
+ export type FileRouter = Readonly<Record<string, FileRouteDefinition<any, any, any, any>>>;
32
+ declare class FileRouteBuilder<Provider extends string, Input = unknown, Metadata = undefined, ServerData = undefined> {
33
+ #private;
34
+ constructor(definition: FileRouteDefinition<Provider, Input, Metadata, ServerData>);
35
+ input<NextInput>(parser: (value: unknown) => MaybePromise<NextInput>): FileRouteBuilder<Provider, NextInput, Metadata, ServerData>;
36
+ middleware<NextMetadata>(authorize: (context: FileRouteContext<Input>) => MaybePromise<NextMetadata>): FileRouteBuilder<Provider, Input, NextMetadata, ServerData>;
37
+ onUploadComplete<NextServerData>(callback: (context: UploadCompleteContext<Metadata, Input>) => MaybePromise<NextServerData>): FileRouteBuilder<Provider, Input, Metadata, NextServerData>;
38
+ onUploadError(callback: NonNullable<FileRouteDefinition["onError"]>): FileRouteBuilder<Provider, Input, Metadata, ServerData>;
39
+ build(): FileRouteDefinition<Provider, Input, Metadata, ServerData>;
40
+ }
41
+ export declare function createFileRoute(): <Provider extends string>(config: FileRouteConfig<Provider>) => FileRouteBuilder<Provider, unknown, undefined, undefined>;
42
+ export declare function defineFileRouter<const Router extends Record<string, FileRouteBuilder<any, any, any, any> | FileRouteDefinition>>(router: Router): {
43
+ [Key in keyof Router]: Router[Key] extends FileRouteBuilder<infer P, infer I, infer M, infer S> ? FileRouteDefinition<P, I, M, S> : Router[Key];
44
+ };
45
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@minhvuong/pirate-storage-server",
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
+ "@minhvuong/pirate-storage": "workspace:*"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "^1.3.10",
29
+ "typescript": "catalog:"
30
+ }
31
+ }