create-monolayer-app 0.2.2 → 0.3.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/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/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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Validate JSON at the client/server boundary before using it as a protocol value.
|
|
2
|
+
/* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-parameters */
|
|
3
|
+
import type {
|
|
4
|
+
FileMultipartUploadDescriptor,
|
|
5
|
+
GetUrlOptions,
|
|
6
|
+
PutOptions,
|
|
7
|
+
PutUrlOptions,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
type UploadMetadata = Pick<
|
|
11
|
+
PutOptions,
|
|
12
|
+
"contentType" | "metadata" | "cacheControl"
|
|
13
|
+
>;
|
|
14
|
+
export type FileBucketRequest = { bucketId: string } & (
|
|
15
|
+
| ({ operation: "put"; path: string; size: number } & UploadMetadata)
|
|
16
|
+
| { operation: "get"; path: string }
|
|
17
|
+
| ({ operation: "getUrl"; path: string } & GetUrlOptions)
|
|
18
|
+
| ({ operation: "putUrl"; path: string } & PutUrlOptions)
|
|
19
|
+
| { operation: "delete"; path: string }
|
|
20
|
+
| { operation: "deleteBatch"; paths: string[] }
|
|
21
|
+
);
|
|
22
|
+
export type FileBucketOperation = FileBucketRequest["operation"];
|
|
23
|
+
export type UploadGrant =
|
|
24
|
+
| { type: "single"; url: string; headers: Record<string, string> }
|
|
25
|
+
| { type: "multipart"; descriptor: FileMultipartUploadDescriptor };
|
|
26
|
+
export interface SignedUrl {
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
export interface FileBucketResponses {
|
|
30
|
+
put: UploadGrant;
|
|
31
|
+
get: SignedUrl;
|
|
32
|
+
getUrl: SignedUrl;
|
|
33
|
+
putUrl: SignedUrl;
|
|
34
|
+
delete: undefined;
|
|
35
|
+
deleteBatch: undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ParseRequestOptions {
|
|
39
|
+
maxBatchKeys?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_KEY_BYTES = 1024;
|
|
43
|
+
const MAX_METADATA_ENTRIES = 50;
|
|
44
|
+
const MAX_METADATA_KEY_LENGTH = 128;
|
|
45
|
+
const MAX_METADATA_VALUE_LENGTH = 2048;
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function textByteLength(value: string): number {
|
|
51
|
+
return new TextEncoder().encode(value).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isKey(value: unknown): value is string {
|
|
55
|
+
return (
|
|
56
|
+
typeof value === "string" &&
|
|
57
|
+
value.length > 0 &&
|
|
58
|
+
textByteLength(value) <= MAX_KEY_BYTES
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
function isText(value: unknown): value is string {
|
|
62
|
+
return typeof value === "string" && value.length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isOptionalText(
|
|
66
|
+
value: unknown,
|
|
67
|
+
maxLen = 1024
|
|
68
|
+
): value is string | undefined {
|
|
69
|
+
return (
|
|
70
|
+
value === undefined ||
|
|
71
|
+
(typeof value === "string" && textByteLength(value) <= maxLen)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
75
|
+
if (!isRecord(value)) return false;
|
|
76
|
+
const entries = Object.entries(value);
|
|
77
|
+
if (entries.length > MAX_METADATA_ENTRIES) return false;
|
|
78
|
+
return entries.every(
|
|
79
|
+
([k, v]) =>
|
|
80
|
+
typeof v === "string" &&
|
|
81
|
+
k.length <= MAX_METADATA_KEY_LENGTH &&
|
|
82
|
+
v.length <= MAX_METADATA_VALUE_LENGTH
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function isSize(value: unknown): value is number {
|
|
86
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
87
|
+
}
|
|
88
|
+
function isMetadata(input: Record<string, unknown>): boolean {
|
|
89
|
+
return (
|
|
90
|
+
isOptionalText(input.contentType, 512) &&
|
|
91
|
+
isOptionalText(input.cacheControl, 512) &&
|
|
92
|
+
(input.metadata === undefined || isStringRecord(input.metadata))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function isSigningOptions(input: Record<string, unknown>): boolean {
|
|
96
|
+
return (
|
|
97
|
+
isMetadata(input) &&
|
|
98
|
+
isOptionalText(input.contentDisposition, 512) &&
|
|
99
|
+
(input.expiresIn === undefined ||
|
|
100
|
+
(isSize(input.expiresIn) &&
|
|
101
|
+
input.expiresIn >= 1 &&
|
|
102
|
+
input.expiresIn <= 604_800))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function deepFreeze<T>(object: T): Readonly<T> {
|
|
107
|
+
if (object === null || typeof object !== "object") return object;
|
|
108
|
+
Object.freeze(object);
|
|
109
|
+
for (const key of Object.keys(object)) {
|
|
110
|
+
// SAFETY: The object has been verified as a non-null object.
|
|
111
|
+
const val = (object as Record<string, unknown>)[key];
|
|
112
|
+
if (val !== null && typeof val === "object" && !Object.isFrozen(val)) {
|
|
113
|
+
deepFreeze(val);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return object;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRequest(
|
|
120
|
+
input: Record<string, unknown>,
|
|
121
|
+
maxBatchKeys = 1000
|
|
122
|
+
): input is Record<string, unknown> & FileBucketRequest {
|
|
123
|
+
if (!isKey(input.bucketId)) return false;
|
|
124
|
+
if (input.operation === "deleteBatch") {
|
|
125
|
+
return (
|
|
126
|
+
Array.isArray(input.paths) &&
|
|
127
|
+
input.paths.length > 0 &&
|
|
128
|
+
input.paths.length <= maxBatchKeys &&
|
|
129
|
+
input.paths.every(isKey)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!isKey(input.path)) return false;
|
|
133
|
+
switch (input.operation) {
|
|
134
|
+
case "get":
|
|
135
|
+
case "delete": {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
case "getUrl":
|
|
139
|
+
case "putUrl": {
|
|
140
|
+
return isSigningOptions(input);
|
|
141
|
+
}
|
|
142
|
+
case "put": {
|
|
143
|
+
return isSize(input.size) && isMetadata(input);
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseFileBucketRequest(
|
|
152
|
+
value: unknown,
|
|
153
|
+
options?: ParseRequestOptions
|
|
154
|
+
): Readonly<FileBucketRequest> {
|
|
155
|
+
if (!isRecord(value)) throw new Error("Invalid file request");
|
|
156
|
+
// Preserve the original upload protocol, which allowed operation to be omitted.
|
|
157
|
+
const input = {
|
|
158
|
+
...value,
|
|
159
|
+
operation: value.operation === undefined ? "put" : value.operation,
|
|
160
|
+
};
|
|
161
|
+
if (!isRequest(input, options?.maxBatchKeys))
|
|
162
|
+
throw new Error("Invalid file request");
|
|
163
|
+
return deepFreeze(input);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isHttpUrl(value: unknown): value is string {
|
|
167
|
+
if (!isText(value)) return false;
|
|
168
|
+
try {
|
|
169
|
+
const url = new URL(value);
|
|
170
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function isMultipart(value: unknown): value is FileMultipartUploadDescriptor {
|
|
176
|
+
if (!isRecord(value)) return false;
|
|
177
|
+
return (
|
|
178
|
+
value.__blocks === "file-bucket/multipart-upload" &&
|
|
179
|
+
isText(value.uploadId) &&
|
|
180
|
+
isSize(value.size) &&
|
|
181
|
+
isSize(value.partSize) &&
|
|
182
|
+
value.partSize >= 5 * 1024 * 1024 &&
|
|
183
|
+
isSize(value.expiresAt) &&
|
|
184
|
+
Array.isArray(value.partUrls) &&
|
|
185
|
+
value.partUrls.length ===
|
|
186
|
+
Math.max(1, Math.ceil(value.size / value.partSize)) &&
|
|
187
|
+
value.partUrls.length <= 10_000 &&
|
|
188
|
+
value.partUrls.every(isHttpUrl) &&
|
|
189
|
+
isHttpUrl(value.completeUrl) &&
|
|
190
|
+
isHttpUrl(value.abortUrl)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
function isUploadGrant(value: unknown): value is UploadGrant {
|
|
194
|
+
if (!isRecord(value)) return false;
|
|
195
|
+
return value.type === "single"
|
|
196
|
+
? isHttpUrl(value.url) && isStringRecord(value.headers)
|
|
197
|
+
: value.type === "multipart" && isMultipart(value.descriptor);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseFileBucketResponse<O extends FileBucketOperation>(
|
|
201
|
+
operation: O,
|
|
202
|
+
value: unknown
|
|
203
|
+
): FileBucketResponses[O] {
|
|
204
|
+
let valid: boolean;
|
|
205
|
+
switch (operation) {
|
|
206
|
+
case "delete":
|
|
207
|
+
case "deleteBatch": {
|
|
208
|
+
valid = value === undefined;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "put": {
|
|
212
|
+
valid = isUploadGrant(value);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
default: {
|
|
216
|
+
valid = isRecord(value) && isHttpUrl(value.url);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!valid) throw new Error("Invalid file response.");
|
|
220
|
+
// SAFETY: The operation-specific validation above verifies its corresponding response shape.
|
|
221
|
+
return value as FileBucketResponses[O];
|
|
222
|
+
}
|
|
@@ -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
|
+
}
|