@nuxthub/core
Version:
Build full-stack Nuxt applications, with zero configuration.
252 lines (251 loc) • 8.64 kB
JavaScript
import * as z from "zod";
import { defu } from "defu";
import { getContentType, streamToArrayBuffer } from "./utils.mjs";
import { setHeader, createError, assertMethod, readFormData, getValidatedQuery, readValidatedBody, getRequestWebStream, sendNoContent, getHeader, getValidatedRouterParams } from "h3";
import { parse } from "pathe";
import { joinURL } from "ufo";
import { randomUUID } from "uncrypto";
import { ensureBlob } from "./ensure.mjs";
export function createBlobStorage(driver) {
const blob = {
driver,
async list(options) {
const resolvedOptions = defu(options, {
limit: 1e3
});
return driver.list(resolvedOptions);
},
async serve(event, pathname) {
pathname = decodeURIComponent(pathname);
const arrayBuffer = await driver.getArrayBuffer(pathname);
if (!arrayBuffer) {
throw createError({ message: "File not found", statusCode: 404 });
}
const meta = await driver.head(pathname);
const contentType = meta?.contentType || getContentType(pathname);
setHeader(event, "Content-Type", contentType);
setHeader(event, "Content-Length", arrayBuffer.byteLength);
if (meta?.httpEtag) {
setHeader(event, "etag", meta.httpEtag);
}
return new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(arrayBuffer));
controller.close();
}
});
},
async get(pathname) {
return driver.get(decodeURIComponent(pathname));
},
async put(pathname, body, options = {}) {
pathname = decodeURIComponent(pathname);
const { contentType: optionsContentType, contentLength, addRandomSuffix, prefix, customMetadata, access } = options;
const contentType = optionsContentType || body.type || getContentType(pathname);
const { dir, ext, name: filename } = parse(pathname);
if (addRandomSuffix) {
pathname = joinURL(dir === "." ? "" : dir, `${filename}-${randomUUID().split("-")[0]}${ext}`);
} else {
pathname = joinURL(dir === "." ? "" : dir, `${filename}${ext}`);
}
if (prefix) {
pathname = joinURL(prefix, pathname).replace(/\/+/g, "/").replace(/^\/+/, "");
}
return driver.put(pathname, body, {
contentType,
contentLength,
customMetadata,
access
});
},
async head(pathname) {
pathname = decodeURIComponent(pathname);
const meta = await driver.head(pathname);
if (!meta) {
throw createError({ message: "Blob not found", statusCode: 404 });
}
return meta;
},
async del(pathnames) {
const paths = Array.isArray(pathnames) ? pathnames : [pathnames];
await driver.delete(paths.map((p) => decodeURIComponent(p)));
},
async delete(pathnames) {
return this.del(pathnames);
},
async createMultipartUpload(pathname, options = {}) {
pathname = decodeURIComponent(pathname);
const { contentType: optionsContentType, addRandomSuffix, prefix } = options;
const contentType = optionsContentType || getContentType(pathname);
const { dir, ext, name: filename } = parse(pathname);
if (addRandomSuffix) {
pathname = joinURL(dir === "." ? "" : dir, `${filename}-${randomUUID().split("-")[0]}${ext}`);
} else {
pathname = joinURL(dir === "." ? "" : dir, `${filename}${ext}`);
}
if (prefix) {
pathname = joinURL(prefix, pathname).replace(/\/+/g, "/").replace(/^\/+/, "");
}
return driver.createMultipartUpload(pathname, {
...options,
contentType
});
},
async resumeMultipartUpload(pathname, uploadId) {
return driver.resumeMultipartUpload(decodeURIComponent(pathname), uploadId);
},
async handleUpload(event, options = {}) {
assertMethod(event, ["POST", "PUT", "PATCH"]);
options = defu(options, {
formKey: "files",
multiple: true
});
const form = await readFormData(event);
const files = form.getAll(options.formKey);
if (!files) {
throw createError({ statusCode: 400, message: "Missing files" });
}
if (!options.multiple && files.length > 1) {
throw createError({ statusCode: 400, message: "Multiple files are not allowed" });
}
const objects = [];
try {
if (options.ensure) {
for (const file of files) {
ensureBlob(file, options.ensure);
}
}
for (const file of files) {
const object = await blob.put(file.name, file, options.put);
objects.push(object);
}
} catch (e) {
throw createError({
statusCode: 500,
message: `Storage error: ${e.message}`
});
}
return objects;
}
};
return {
...blob,
handleMultipartUpload: driver.handleMultipartUpload ?? createGenericMultipartUploadHandler(blob)
};
}
function createGenericMultipartUploadHandler(blob) {
const createHandler = async (event, options) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse);
options ||= {};
if (getHeader(event, "x-nuxthub-file-content-type")) {
options.contentType ||= getHeader(event, "x-nuxthub-file-content-type");
}
try {
const object = await blob.createMultipartUpload(pathname, options);
return {
uploadId: object.uploadId,
pathname: object.pathname
};
} catch (e) {
throw createError({
statusCode: 400,
message: e.message
});
}
};
const uploadHandler = async (event) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse);
const { uploadId, partNumber } = await getValidatedQuery(event, z.object({
uploadId: z.string(),
partNumber: z.coerce.number()
}).parse);
const contentLength = Number(getHeader(event, "content-length") || "0");
const stream = getRequestWebStream(event);
const body = await streamToArrayBuffer(stream, contentLength);
const mpu = await blob.resumeMultipartUpload(pathname, uploadId);
try {
return await mpu.uploadPart(partNumber, body);
} catch (e) {
throw createError({ status: 400, message: e.message });
}
};
const completeHandler = async (event) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse);
const { uploadId } = await getValidatedQuery(event, z.object({
uploadId: z.string().min(1)
}).parse);
const { parts } = await readValidatedBody(event, z.object({
parts: z.array(z.object({
partNumber: z.number(),
etag: z.string()
}))
}).parse);
const mpu = await blob.resumeMultipartUpload(pathname, uploadId);
try {
const object = await mpu.complete(parts);
return object;
} catch (e) {
throw createError({ status: 400, message: e.message });
}
};
const abortHandler = async (event) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse);
const { uploadId } = await getValidatedQuery(event, z.object({
uploadId: z.string().min(1)
}).parse);
const mpu = await blob.resumeMultipartUpload(pathname, uploadId);
try {
await mpu.abort();
} catch (e) {
throw createError({ status: 400, message: e.message });
}
};
const handler = async (event, options) => {
const method = event.method;
const { action } = await getValidatedRouterParams(event, z.object({
action: z.enum(["create", "upload", "complete", "abort"])
}).parse);
if (action === "create" && method === "POST") {
return {
action,
data: await createHandler(event, options)
};
}
if (action === "upload" && method === "PUT") {
return {
action,
data: await uploadHandler(event)
};
}
if (action === "complete" && method === "POST") {
return {
action,
data: await completeHandler(event)
};
}
if (action === "abort" && method === "DELETE") {
return {
action,
data: await abortHandler(event)
};
}
throw createError({ status: 405 });
};
return async (event, options) => {
const result = await handler(event, options);
if (result.data) {
event.respondWith(Response.json(result.data));
} else {
sendNoContent(event);
}
return result;
};
}