create-monolayer-app 0.2.2 → 0.4.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/package.json +2 -2
- package/template/_gitignore +9 -4
- package/template/app/routes/files.ts +63 -0
- package/template/app/routes.ts +5 -2
- package/template/oxfmt.config.ts +6 -1
- package/template/oxlint.config.ts +3 -8
- package/template/package.json +2 -1
- package/template/vendor/file-bucket/client.ts +196 -0
- package/template/vendor/file-bucket/errors.ts +26 -0
- package/template/vendor/file-bucket/index.ts +7 -0
- package/template/vendor/file-bucket/multipart.ts +304 -0
- package/template/vendor/file-bucket/protocol.ts +222 -0
- package/template/vendor/file-bucket/react-router.ts +222 -0
- package/template/vendor/file-bucket/server.ts +544 -0
- package/template/vendor/file-bucket/transfers.ts +23 -0
- package/template/vendor/file-bucket/transport.ts +37 -0
- package/template/vendor/file-bucket/types.ts +153 -0
- package/template/vendor/file-bucket/vite.d.ts +7 -0
- package/template/vendor/file-bucket/vite.js +2482 -0
- package/template/vendor/file-bucket/xml.ts +325 -0
- package/template/vendor/package-app/package-app.mjs +116 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/* oxlint-disable no-await-in-loop, anti-slop/no-unknown-returns, anti-slop/no-runtime-typeof */
|
|
2
|
+
import { FileBucket } from "./index.js";
|
|
3
|
+
import { parseFileBucketRequest } from "./protocol.js";
|
|
4
|
+
import type {
|
|
5
|
+
FileBucketRequest,
|
|
6
|
+
FileBucketResponses,
|
|
7
|
+
UploadGrant,
|
|
8
|
+
} from "./protocol.js";
|
|
9
|
+
import {
|
|
10
|
+
getUrl,
|
|
11
|
+
putUrl,
|
|
12
|
+
delete as deleteFile,
|
|
13
|
+
deleteBatch,
|
|
14
|
+
createMultipartUploadHandle,
|
|
15
|
+
} from "./server.js";
|
|
16
|
+
|
|
17
|
+
const MULTIPART_THRESHOLD = 8 * 1024 * 1024;
|
|
18
|
+
export const MAX_REQUEST_BYTES = 64 * 1024;
|
|
19
|
+
export const MAX_BATCH_KEYS = 1000;
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
FileBucketRequest,
|
|
23
|
+
FileBucketOperation,
|
|
24
|
+
UploadGrant,
|
|
25
|
+
SignedUrl,
|
|
26
|
+
FileBucketResponses,
|
|
27
|
+
} from "./protocol.js";
|
|
28
|
+
|
|
29
|
+
export async function readLimitedJson(
|
|
30
|
+
request: Request,
|
|
31
|
+
maxBytes: number,
|
|
32
|
+
): Promise<unknown> {
|
|
33
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
34
|
+
throw new RangeError("maxBytes must be a positive safe integer.");
|
|
35
|
+
}
|
|
36
|
+
const reader = request.body?.getReader();
|
|
37
|
+
if (!reader) throw new Response("Invalid JSON", { status: 400 });
|
|
38
|
+
|
|
39
|
+
const chunks: Uint8Array[] = [];
|
|
40
|
+
let length = 0;
|
|
41
|
+
try {
|
|
42
|
+
while (true) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
length += value.byteLength;
|
|
46
|
+
if (length > maxBytes) {
|
|
47
|
+
try {
|
|
48
|
+
await reader.cancel();
|
|
49
|
+
} catch {
|
|
50
|
+
// Preserve 413 if canceling fails
|
|
51
|
+
}
|
|
52
|
+
throw new Response("Request too large", { status: 413 });
|
|
53
|
+
}
|
|
54
|
+
chunks.push(value);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
reader.releaseLock();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bytes = new Uint8Array(length);
|
|
61
|
+
let offset = 0;
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
bytes.set(chunk, offset);
|
|
64
|
+
offset += chunk.byteLength;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Response("Invalid JSON", { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function uploadGrant(
|
|
74
|
+
bucket: FileBucket,
|
|
75
|
+
input: Extract<FileBucketRequest, { operation: "put" }>,
|
|
76
|
+
): Promise<UploadGrant> {
|
|
77
|
+
const options = {
|
|
78
|
+
contentType: input.contentType,
|
|
79
|
+
cacheControl: input.cacheControl,
|
|
80
|
+
metadata: input.metadata,
|
|
81
|
+
};
|
|
82
|
+
if (input.size >= MULTIPART_THRESHOLD) {
|
|
83
|
+
const handle = await createMultipartUploadHandle(bucket, input.path, {
|
|
84
|
+
...options,
|
|
85
|
+
size: input.size,
|
|
86
|
+
});
|
|
87
|
+
return { type: "multipart", descriptor: handle.toJSON() };
|
|
88
|
+
}
|
|
89
|
+
const url = await putUrl(bucket, input.path, options);
|
|
90
|
+
const headers: Record<string, string> = {};
|
|
91
|
+
if (options.contentType) headers["Content-Type"] = options.contentType;
|
|
92
|
+
if (options.cacheControl) headers["Cache-Control"] = options.cacheControl;
|
|
93
|
+
return { type: "single", url, headers };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function dispatch(
|
|
97
|
+
bucket: FileBucket,
|
|
98
|
+
input: FileBucketRequest,
|
|
99
|
+
): Promise<FileBucketResponses[FileBucketRequest["operation"]]> {
|
|
100
|
+
switch (input.operation) {
|
|
101
|
+
case "delete": {
|
|
102
|
+
await deleteFile(bucket, input.path);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
case "deleteBatch": {
|
|
106
|
+
await deleteBatch(bucket, input.paths);
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
case "get": {
|
|
110
|
+
return {
|
|
111
|
+
url: await getUrl(bucket, input.path, {
|
|
112
|
+
contentDisposition: "attachment",
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
case "getUrl": {
|
|
117
|
+
return {
|
|
118
|
+
url: await getUrl(bucket, input.path, {
|
|
119
|
+
expiresIn: input.expiresIn,
|
|
120
|
+
contentDisposition: input.contentDisposition,
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
case "putUrl": {
|
|
125
|
+
return {
|
|
126
|
+
url: await putUrl(bucket, input.path, {
|
|
127
|
+
expiresIn: input.expiresIn,
|
|
128
|
+
contentType: input.contentType,
|
|
129
|
+
metadata: input.metadata,
|
|
130
|
+
cacheControl: input.cacheControl,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
case "put": {
|
|
135
|
+
return uploadGrant(bucket, input);
|
|
136
|
+
}
|
|
137
|
+
default: {
|
|
138
|
+
throw new Error("Unsupported file operation");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Creates a React Router server action for handling browser FileBucket operations.
|
|
145
|
+
*
|
|
146
|
+
* Register the returned action at `/resources/files`. It handles JSON POST requests for:
|
|
147
|
+
* - Direct upload signing (`put`)
|
|
148
|
+
* - Attachment download signing (`get`)
|
|
149
|
+
* - Presigned URL generation (`getUrl`, `putUrl`)
|
|
150
|
+
* - Server-side deletion (`delete`, `deleteBatch`)
|
|
151
|
+
*
|
|
152
|
+
* Resolves the `FileBucket` instance directly from the request's bucket ID.
|
|
153
|
+
* Limits request bodies to 64 KiB and batch deletions to 1,000 keys.
|
|
154
|
+
*
|
|
155
|
+
* Returns JSON for signing, an empty 204 response for successful deletion,
|
|
156
|
+
* 400 for malformed requests, 405 for methods other than POST, 413 for oversized requests,
|
|
157
|
+
* and 415 for non-JSON content types.
|
|
158
|
+
* Storage failures propagate to React Router. Route registration is the caller's responsibility.
|
|
159
|
+
*
|
|
160
|
+
* @returns A React Router action function `({ request }: { request: Request }) => Promise<Response>`.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* // app/routes/files.ts
|
|
165
|
+
* import { createFileBucketAction } from "@monolayer/file-bucket/react-router";
|
|
166
|
+
*
|
|
167
|
+
* export const action = createFileBucketAction();
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function createFileBucketAction(): (args: {
|
|
171
|
+
request: Request;
|
|
172
|
+
}) => Promise<Response> {
|
|
173
|
+
return async ({ request }: { request: Request }): Promise<Response> => {
|
|
174
|
+
if (request.method !== "POST") {
|
|
175
|
+
return new Response("Method not allowed", {
|
|
176
|
+
status: 405,
|
|
177
|
+
headers: { Allow: "POST" },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const contentTypeHeader = request.headers.get("content-type");
|
|
182
|
+
const mediaType = contentTypeHeader?.toLowerCase().split(";")[0]?.trim();
|
|
183
|
+
if (mediaType !== "application/json") {
|
|
184
|
+
return new Response("Unsupported Media Type", { status: 415 });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const contentLengthHeader = request.headers.get("content-length");
|
|
188
|
+
if (contentLengthHeader !== null) {
|
|
189
|
+
const declaredLength = Number(contentLengthHeader);
|
|
190
|
+
if (!Number.isNaN(declaredLength) && declaredLength > MAX_REQUEST_BYTES) {
|
|
191
|
+
return new Response("Request too large", { status: 413 });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let value: unknown;
|
|
196
|
+
try {
|
|
197
|
+
value = await readLimitedJson(request, MAX_REQUEST_BYTES);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof Response) return error;
|
|
200
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let input: Readonly<FileBucketRequest>;
|
|
204
|
+
try {
|
|
205
|
+
input = parseFileBucketRequest(value, { maxBatchKeys: MAX_BATCH_KEYS });
|
|
206
|
+
} catch {
|
|
207
|
+
return new Response("Invalid file request", { status: 400 });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const bucket = new FileBucket(input.bucketId);
|
|
211
|
+
const result = await dispatch(bucket, input);
|
|
212
|
+
if (result === undefined) {
|
|
213
|
+
return new Response(null, {
|
|
214
|
+
status: 204,
|
|
215
|
+
headers: { "Cache-Control": "no-store" },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return Response.json(result, {
|
|
219
|
+
headers: { "Cache-Control": "private, no-store" },
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
}
|