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.
@@ -0,0 +1,544 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Readable, addAbortSignal } from "node:stream";
3
+
4
+ import {
5
+ S3Client,
6
+ PutObjectCommand,
7
+ GetObjectCommand,
8
+ DeleteObjectCommand,
9
+ DeleteObjectsCommand,
10
+ ListObjectsV2Command,
11
+ CreateMultipartUploadCommand,
12
+ UploadPartCommand,
13
+ CompleteMultipartUploadCommand,
14
+ AbortMultipartUploadCommand,
15
+ } from "@aws-sdk/client-s3";
16
+ import { Upload } from "@aws-sdk/lib-storage";
17
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
18
+
19
+ import type { FileBucket } from "./index.js";
20
+ import { createMultipartUploadClient, multipartPartSize } from "./multipart.js";
21
+ import { uploadSignedUrl } from "./transfers.js";
22
+ import type {
23
+ FileUploadBody,
24
+ MultipartUploadOptions,
25
+ FileMultipartUploadClient,
26
+ PutOptions,
27
+ GetUrlOptions,
28
+ PutUrlOptions,
29
+ ScanOptions,
30
+ FileContent,
31
+ FileInfo,
32
+ FileDownloadClient,
33
+ FileUploadClient,
34
+ } from "./types.js";
35
+
36
+ // Re-export public types
37
+ export { FileBucketErrors } from "./errors.js";
38
+ export type {
39
+ FileUploadBody,
40
+ MultipartUploadOptions,
41
+ FileMultipartUploadClient,
42
+ FileMultipartUploadDescriptor,
43
+ UploadOptions,
44
+ UploadProgress,
45
+ PutOptions,
46
+ GetUrlOptions,
47
+ PutUrlOptions,
48
+ ScanOptions,
49
+ FileContent,
50
+ FileInfo,
51
+ FileDownloadClient,
52
+ FileUploadClient,
53
+ FileDownloadDescriptor,
54
+ FileUploadDescriptor,
55
+ } from "./types.js";
56
+
57
+ function uploadBodySize(body: FileUploadBody): number | undefined {
58
+ // The public body union includes strings, byte arrays, blobs, and streams.
59
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
60
+ if (typeof body === "string") return Buffer.byteLength(body);
61
+ if (body instanceof Uint8Array) return body.byteLength;
62
+ if (body instanceof Blob) return body.size;
63
+ return undefined;
64
+ }
65
+
66
+ function cancellableBody(
67
+ body: FileUploadBody,
68
+ signal?: AbortSignal
69
+ ): FileUploadBody {
70
+ if (!signal) return body;
71
+ if (body instanceof Readable) return addAbortSignal(signal, body);
72
+ if (body instanceof ReadableStream) {
73
+ return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>(), {
74
+ signal,
75
+ });
76
+ }
77
+ return body;
78
+ }
79
+
80
+ async function abortFailedUpload(
81
+ client: S3Client,
82
+ bucket: string,
83
+ key: string,
84
+ uploadId: string
85
+ ): Promise<void> {
86
+ try {
87
+ await client.send(
88
+ new AbortMultipartUploadCommand({
89
+ Bucket: bucket,
90
+ Key: key,
91
+ UploadId: uploadId,
92
+ })
93
+ );
94
+ } catch (error) {
95
+ if (!(error instanceof Error && error.name === "NoSuchUpload")) throw error;
96
+ }
97
+ }
98
+
99
+ const clients = new WeakMap<FileBucket, S3Client>();
100
+ function getClient(bucket: FileBucket): S3Client {
101
+ const client =
102
+ clients.get(bucket) ??
103
+ new S3Client(
104
+ process.env.NODE_ENV === "production"
105
+ ? { requestChecksumCalculation: "WHEN_REQUIRED" }
106
+ : {
107
+ requestChecksumCalculation: "WHEN_REQUIRED",
108
+ endpoint:
109
+ process.env.S3_EMULATOR_ENDPOINT || "http://127.0.0.1:4566",
110
+ region: "us-east-1",
111
+ forcePathStyle: true,
112
+ credentials: {
113
+ accessKeyId: "test",
114
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test",
115
+ },
116
+ }
117
+ );
118
+ clients.set(bucket, client);
119
+ return client;
120
+ }
121
+ /**
122
+ * Upload an object from the server, managing multipart transfers automatically.
123
+ *
124
+ * Reusing a key overwrites its object. Cancellation or failure attempts multipart
125
+ * cleanup. Progress reports transferred bytes; total size can be unknown for streams.
126
+ *
127
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
128
+ * @param path - Destination object key.
129
+ * @param body - String, byte array, Blob, Node stream, or Web stream to upload.
130
+ * @param options - Object metadata, content headers, cancellation, and progress callback.
131
+ * @returns A promise that resolves after S3 confirms completion.
132
+ */
133
+ export async function put(
134
+ bucket: FileBucket,
135
+ path: string,
136
+ body: FileUploadBody,
137
+ options?: PutOptions
138
+ ): Promise<void> {
139
+ const bucketName = resolveBucketName(bucket);
140
+ options?.signal?.throwIfAborted();
141
+ const client = getClient(bucket);
142
+ const size = uploadBodySize(body);
143
+ const controller = new AbortController();
144
+ const abort = () => controller.abort();
145
+ const upload = new Upload({
146
+ client,
147
+ params: {
148
+ Bucket: bucketName,
149
+ Key: path,
150
+ Body: cancellableBody(body, options?.signal),
151
+ ContentType: options?.contentType,
152
+ Metadata: options?.metadata,
153
+ CacheControl: options?.cacheControl,
154
+ },
155
+ partSize: multipartPartSize(size),
156
+ queueSize: 4,
157
+ leavePartsOnError: false,
158
+ abortController: controller,
159
+ });
160
+ options?.signal?.addEventListener("abort", abort, { once: true });
161
+ if (options?.onProgress) {
162
+ upload.on("httpUploadProgress", (progress) => {
163
+ options.onProgress?.({
164
+ uploadedBytes: progress.loaded ?? 0,
165
+ totalBytes: progress.total,
166
+ });
167
+ });
168
+ }
169
+ try {
170
+ await upload.done();
171
+ } catch (error) {
172
+ // lib-storage cleans up failed parts; also cover completion failures.
173
+ if (upload.uploadId) {
174
+ try {
175
+ await abortFailedUpload(client, bucketName, path, upload.uploadId);
176
+ } catch (cleanupError) {
177
+ throw new AggregateError(
178
+ [error, cleanupError],
179
+ "Upload failed and its multipart session could not be cleaned up.",
180
+ { cause: cleanupError }
181
+ );
182
+ }
183
+ }
184
+ throw error;
185
+ } finally {
186
+ options?.signal?.removeEventListener("abort", abort);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Read an entire object into memory on the server.
192
+ *
193
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
194
+ * @param path - Object key to read.
195
+ * @returns Buffered content and metadata, or `null` for a missing key. Other S3 errors reject the promise.
196
+ */
197
+ export async function get(
198
+ bucket: FileBucket,
199
+ path: string
200
+ ): Promise<FileContent | null> {
201
+ const bucketName = resolveBucketName(bucket);
202
+ const client = getClient(bucket);
203
+ try {
204
+ const result = await client.send(
205
+ new GetObjectCommand({
206
+ Bucket: bucketName,
207
+ Key: path,
208
+ })
209
+ );
210
+ if (!result.Body) throw new Error("S3 returned an empty response body");
211
+ const bytes = await result.Body.transformToByteArray();
212
+ return {
213
+ body: Buffer.from(bytes),
214
+ contentType: result.ContentType ?? "application/octet-stream",
215
+ metadata: result.Metadata ?? {},
216
+ size: result.ContentLength ?? bytes.length,
217
+ };
218
+ } catch (error) {
219
+ if (error instanceof Error && error.name === "NoSuchKey") return null;
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Delete an object through S3. Deleting a missing key succeeds.
226
+ *
227
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
228
+ * @param path - Object key to delete.
229
+ * @returns A promise that resolves after S3 confirms deletion.
230
+ */
231
+ async function deleteFile(bucket: FileBucket, path: string): Promise<void> {
232
+ const bucketName = resolveBucketName(bucket);
233
+ const client = getClient(bucket);
234
+ await client.send(
235
+ new DeleteObjectCommand({
236
+ Bucket: bucketName,
237
+ Key: path,
238
+ })
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Delete objects in sequential batches of up to 1,000 keys.
244
+ *
245
+ * An empty list sends no S3 request. Deletion is not atomic: a rejected batch may
246
+ * already have deleted some objects, and later batches are not attempted.
247
+ *
248
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
249
+ * @param paths - Object keys to delete.
250
+ * @returns A promise that resolves after all batches complete.
251
+ * @throws {AggregateError} If S3 reports per-object deletion errors.
252
+ */
253
+ export async function deleteBatch(
254
+ bucket: FileBucket,
255
+ paths: string[]
256
+ ): Promise<void> {
257
+ if (paths.length === 0) return;
258
+ const bucketName = resolveBucketName(bucket);
259
+ const client = getClient(bucket);
260
+ const CHUNK_SIZE = 1000;
261
+ for (let i = 0; i < paths.length; i += CHUNK_SIZE) {
262
+ const chunk = paths.slice(i, i + CHUNK_SIZE);
263
+ // oxlint-disable-next-line no-await-in-loop
264
+ const result = await client.send(
265
+ new DeleteObjectsCommand({
266
+ Bucket: bucketName,
267
+ Delete: { Objects: chunk.map((Key) => ({ Key })), Quiet: true },
268
+ })
269
+ );
270
+ if (result.Errors?.length) {
271
+ throw new AggregateError(
272
+ result.Errors.map(
273
+ (error) =>
274
+ new Error(
275
+ `Failed to delete ${error.Key}: ${error.Code} ${error.Message ?? ""}`
276
+ )
277
+ ),
278
+ "Some files could not be deleted."
279
+ );
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Sign a GET URL on the server without reading the object or checking that it exists.
286
+ *
287
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
288
+ * @param path - Object key to access.
289
+ * @param options - URL lifetime in seconds (default 3600) and optional response content disposition.
290
+ * @returns A signed URL; requesting it performs the download.
291
+ */
292
+ export function getUrl(
293
+ bucket: FileBucket,
294
+ path: string,
295
+ options?: GetUrlOptions
296
+ ): Promise<string> {
297
+ const bucketName = resolveBucketName(bucket);
298
+ return getSignedUrl(
299
+ getClient(bucket),
300
+ new GetObjectCommand({
301
+ Bucket: bucketName,
302
+ Key: path,
303
+ ResponseContentDisposition: options?.contentDisposition,
304
+ }),
305
+ {
306
+ expiresIn: options?.expiresIn ?? 3600,
307
+ }
308
+ );
309
+ }
310
+
311
+ /**
312
+ * Sign a URL for one PUT without uploading bytes or creating a multipart session.
313
+ *
314
+ * Send the raw body with the content-type and cache-control headers used when
315
+ * signing. Metadata is carried in the signed query. Uploading replaces an existing key.
316
+ *
317
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
318
+ * @param path - Destination object key.
319
+ * @param options - Object metadata, content headers, and URL lifetime in seconds (default 3600).
320
+ * @returns A signed URL for a single PUT.
321
+ */
322
+ export function putUrl(
323
+ bucket: FileBucket,
324
+ path: string,
325
+ options?: PutUrlOptions
326
+ ): Promise<string> {
327
+ const bucketName = resolveBucketName(bucket);
328
+ return getSignedUrl(
329
+ getClient(bucket),
330
+ new PutObjectCommand({
331
+ Bucket: bucketName,
332
+ Key: path,
333
+ ContentType: options?.contentType,
334
+ Metadata: options?.metadata,
335
+ CacheControl: options?.cacheControl,
336
+ }),
337
+ { expiresIn: options?.expiresIn ?? 3600 }
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Create an S3 multipart session and sign its part, completion, and abort URLs.
343
+ *
344
+ * The returned handle uploads once, supports cancellation and progress, and can
345
+ * abort an unused session. Serialize with `toJSON()` and hydrate in the browser
346
+ * with `createMultipartUploadClient`. Creation sends no file bytes.
347
+ *
348
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
349
+ * @param path - Destination object key.
350
+ * @param options - Expected byte size, object metadata, content headers, and URL lifetime (default 3600 seconds).
351
+ * @returns A multipart handle tied to the newly created session.
352
+ * @throws {RangeError} For an unsupported size or URL lifetime.
353
+ */
354
+ export async function createMultipartUploadHandle(
355
+ bucket: FileBucket,
356
+ path: string,
357
+ options: MultipartUploadOptions
358
+ ): Promise<FileMultipartUploadClient> {
359
+ const bucketName = resolveBucketName(bucket);
360
+ const partSize = multipartPartSize(options.size);
361
+ const expiresIn = options.expiresIn ?? 3600;
362
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604_800) {
363
+ throw new RangeError("URL lifetime must be between 1 and 604800 seconds.");
364
+ }
365
+ const client = getClient(bucket);
366
+ const target = { Bucket: bucketName, Key: path };
367
+ const { UploadId } = await client.send(
368
+ new CreateMultipartUploadCommand({
369
+ ...target,
370
+ ContentType: options.contentType,
371
+ Metadata: options.metadata,
372
+ CacheControl: options.cacheControl,
373
+ })
374
+ );
375
+ if (!UploadId) throw new Error("S3 did not return an upload ID.");
376
+ const session = { ...target, UploadId };
377
+ // SigV4 timestamps have one-second precision; expire conservatively.
378
+ const expiresAt = Math.floor(Date.now() / 1000) * 1000 + expiresIn * 1000;
379
+ try {
380
+ const partUrls: string[] = [];
381
+ const count = Math.max(1, Math.ceil(options.size / partSize));
382
+ for (let partNumber = 1; partNumber <= count; partNumber += 1) {
383
+ partUrls.push(
384
+ // oxlint-disable-next-line no-await-in-loop
385
+ await getSignedUrl(
386
+ client,
387
+ new UploadPartCommand({
388
+ ...session,
389
+ PartNumber: partNumber,
390
+ }),
391
+ { expiresIn }
392
+ )
393
+ );
394
+ }
395
+ const completeUrl = await getSignedUrl(
396
+ client,
397
+ new CompleteMultipartUploadCommand({
398
+ ...session,
399
+ MultipartUpload: { Parts: [] },
400
+ }),
401
+ { expiresIn, unsignableHeaders: new Set(["content-length"]) }
402
+ );
403
+ const abortUrl = await getSignedUrl(
404
+ client,
405
+ new AbortMultipartUploadCommand(session),
406
+ { expiresIn }
407
+ );
408
+ return createMultipartUploadClient({
409
+ __blocks: "file-bucket/multipart-upload",
410
+ uploadId: UploadId,
411
+ size: options.size,
412
+ partSize,
413
+ expiresAt,
414
+ partUrls,
415
+ completeUrl,
416
+ abortUrl,
417
+ });
418
+ } catch (error) {
419
+ try {
420
+ await client.send(new AbortMultipartUploadCommand(session));
421
+ } catch (cleanupError) {
422
+ throw new AggregateError(
423
+ [error, cleanupError],
424
+ "Failed to create and clean up multipart upload.",
425
+ { cause: cleanupError }
426
+ );
427
+ }
428
+ throw error;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Create a signed download handle on the server without fetching the object.
434
+ *
435
+ * Its `download()` method buffers the response as a Blob and rejects HTTP errors;
436
+ * it does not trigger a native browser download. `toJSON()` returns descriptor
437
+ * data without methods. The signed URL is not refreshed automatically.
438
+ *
439
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
440
+ * @param path - Object key to access.
441
+ * @param options - URL lifetime in seconds (default 3600) and response content disposition.
442
+ * @returns A handle with `download()`, `getUrl()`, and `toJSON()`.
443
+ */
444
+ export async function getFileHandle(
445
+ bucket: FileBucket,
446
+ path: string,
447
+ options?: GetUrlOptions
448
+ ): Promise<FileDownloadClient> {
449
+ const url = await getUrl(bucket, path, options);
450
+ return {
451
+ download: async () => {
452
+ const res = await fetch(url);
453
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
454
+ return res.blob();
455
+ },
456
+ getUrl: () => url,
457
+ toJSON: () => ({ __blocks: "file-bucket/download" as const, url }),
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Create a signed single-PUT handle on the server without uploading bytes.
463
+ *
464
+ * Call `upload(body, { signal, onProgress })` with a Blob, File, or ArrayBuffer.
465
+ * The handle supplies signed content headers, omits browser credentials, rejects
466
+ * HTTP errors, and reports progress after completion. `toJSON()` returns data
467
+ * without methods. The signed URL is not refreshed automatically.
468
+ *
469
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
470
+ * @param path - Destination object key; uploading replaces any existing object.
471
+ * @param options - Object metadata, content headers, and URL lifetime in seconds (default 3600).
472
+ * @returns A handle with `upload()`, `getUrl()`, and `toJSON()`.
473
+ */
474
+ export async function createUploadHandle(
475
+ bucket: FileBucket,
476
+ path: string,
477
+ options?: PutUrlOptions
478
+ ): Promise<FileUploadClient> {
479
+ const url = await putUrl(bucket, path, options);
480
+ const contentType = options?.contentType;
481
+ const cacheControl = options?.cacheControl;
482
+ return {
483
+ upload: async (body, uploadOptions) => {
484
+ const headers: Record<string, string> = {};
485
+ if (contentType) headers["Content-Type"] = contentType;
486
+ if (cacheControl) headers["Cache-Control"] = cacheControl;
487
+ await uploadSignedUrl(url, body, headers, uploadOptions);
488
+ },
489
+ getUrl: () => url,
490
+ toJSON: () => ({
491
+ __blocks: "file-bucket/upload" as const,
492
+ url,
493
+ contentType,
494
+ cacheControl,
495
+ }),
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Iterate object metadata on the server, fetching S3 pages as needed.
501
+ *
502
+ * The physical bucket is resolved when iteration starts. File bodies are not read.
503
+ *
504
+ * @param bucket - Shared definition whose ID is resolved to the configured physical bucket.
505
+ * @param options - Optional object-key prefix filter.
506
+ * @returns An async iterable of object keys, sizes, and modification dates.
507
+ * @yields {FileInfo} Metadata for each matching object.
508
+ */
509
+ export async function* scan(
510
+ bucket: FileBucket,
511
+ options?: ScanOptions
512
+ ): AsyncIterable<FileInfo> {
513
+ const bucketName = resolveBucketName(bucket);
514
+ const client = getClient(bucket);
515
+ let continuationToken: string | undefined;
516
+ do {
517
+ // oxlint-disable-next-line no-await-in-loop
518
+ const result = await client.send(
519
+ new ListObjectsV2Command({
520
+ Bucket: bucketName,
521
+ Prefix: options?.prefix,
522
+ ContinuationToken: continuationToken,
523
+ })
524
+ );
525
+ for (const obj of result.Contents ?? []) {
526
+ if (obj.Key === undefined) continue;
527
+ yield {
528
+ path: obj.Key,
529
+ size: obj.Size ?? 0,
530
+ lastModified: obj.LastModified ?? new Date(),
531
+ };
532
+ }
533
+ continuationToken = result.NextContinuationToken;
534
+ } while (continuationToken);
535
+ }
536
+ export { deleteFile as delete };
537
+
538
+ function resolveBucketName(bucket: FileBucket): string {
539
+ const hash = createHash("sha256")
540
+ .update(bucket.name)
541
+ .digest("hex")
542
+ .slice(0, 8);
543
+ return process.env[`FILE_BUCKET_${hash}`] || bucket.name;
544
+ }
@@ -0,0 +1,23 @@
1
+ import type { UploadOptions } from "./types.js";
2
+
3
+ /** Execute a signed PUT without forwarding application credentials. */
4
+ export async function uploadSignedUrl(
5
+ url: string,
6
+ body: Blob | ArrayBuffer | Uint8Array,
7
+ headers: Record<string, string>,
8
+ options: UploadOptions = {}
9
+ ): Promise<void> {
10
+ options.signal?.throwIfAborted();
11
+ const requestBody: BodyInit =
12
+ body instanceof Uint8Array ? new Uint8Array(body).buffer : body;
13
+ const response = await fetch(url, {
14
+ method: "PUT",
15
+ credentials: "omit",
16
+ headers,
17
+ body: requestBody,
18
+ signal: options.signal,
19
+ });
20
+ if (!response.ok) throw new Error(`Upload failed (${response.status}).`);
21
+ const size = body instanceof Blob ? body.size : body.byteLength;
22
+ options.onProgress?.({ uploadedBytes: size, totalBytes: size });
23
+ }
@@ -0,0 +1,37 @@
1
+ import type {
2
+ FileBucketOperation,
3
+ FileBucketRequest,
4
+ FileBucketResponses,
5
+ } from "./protocol.js";
6
+ import { parseFileBucketResponse } from "./protocol.js";
7
+
8
+ const FAILURE_LABELS: Record<FileBucketOperation, string> = {
9
+ put: "Upload signing",
10
+ get: "Download signing",
11
+ getUrl: "URL signing",
12
+ putUrl: "URL signing",
13
+ delete: "Deletion",
14
+ deleteBatch: "Deletion",
15
+ };
16
+
17
+ export async function request<O extends FileBucketOperation>(
18
+ payload: Extract<FileBucketRequest, { operation: O }>,
19
+ signal?: AbortSignal
20
+ ): Promise<FileBucketResponses[O]> {
21
+ signal?.throwIfAborted();
22
+ const response = await fetch("/resources/files", {
23
+ method: "POST",
24
+ credentials: "same-origin",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify(payload),
27
+ signal,
28
+ });
29
+ if (!response.ok)
30
+ throw new Error(
31
+ `${FAILURE_LABELS[payload.operation]} failed (${response.status}).`
32
+ );
33
+ const value: unknown =
34
+ response.status === 204 ? undefined : await response.json();
35
+ signal?.throwIfAborted();
36
+ return parseFileBucketResponse(payload.operation, value);
37
+ }