@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 +19 -0
- package/dist/handler.d.ts +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +485 -0
- package/dist/router.d.ts +45 -0
- package/package.json +31 -0
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;
|
package/dist/index.d.ts
ADDED
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
|
+
};
|
package/dist/router.d.ts
ADDED
|
@@ -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
|
+
}
|