@nuxthub/core
Version:
Build full-stack Nuxt applications on Cloudflare, with zero configuration.
508 lines (507 loc) • 16.8 kB
JavaScript
import { ofetch } from "ofetch";
import mime from "mime";
import { z } from "zod";
import { getHeader, getRequestWebStream } from "h3";
import { setHeader, createError, readFormData, getValidatedQuery, getValidatedRouterParams, readValidatedBody, sendNoContent, assertMethod } from "h3";
import { defu } from "defu";
import { randomUUID } from "uncrypto";
import { parse } from "pathe";
import { joinURL } from "ufo";
import { streamToArrayBuffer } from "../../../utils/stream.js";
import { requireNuxtHubFeature } from "../../../utils/features.js";
import { getCloudflareAccessHeaders } from "../../../utils/cloudflareAccess.js";
import { useRuntimeConfig } from "#imports";
const _r2_buckets = {};
function getBlobBinding(name = "BLOB") {
return process.env[name] || globalThis.__env__?.[name] || globalThis[name];
}
function _useBucket(name = "BLOB") {
if (_r2_buckets[name]) {
return _r2_buckets[name];
}
const binding = getBlobBinding(name);
if (binding) {
_r2_buckets[name] = binding;
return _r2_buckets[name];
}
throw createError(`Missing Cloudflare ${name} binding (R2)`);
}
export function hubBlob() {
requireNuxtHubFeature("blob");
const hub = useRuntimeConfig().hub;
const binding = getBlobBinding();
if (hub.remote && hub.projectUrl && !binding) {
const cfAccessHeaders = getCloudflareAccessHeaders(hub.cloudflareAccess);
return proxyHubBlob(hub.projectUrl, hub.projectSecretKey || hub.userToken, cfAccessHeaders);
}
const bucket = _useBucket();
const blob = {
async list(options) {
const resolvedOptions = defu(options, {
limit: 1e3,
include: ["httpMetadata", "customMetadata"],
delimiter: options?.folded ? "/" : void 0
});
const listed = await bucket.list(resolvedOptions);
const hasMore = listed.truncated;
const cursor = listed.truncated ? listed.cursor : void 0;
return {
blobs: listed.objects.map(mapR2ObjectToBlob),
hasMore,
cursor,
folders: resolvedOptions.delimiter ? listed.delimitedPrefixes : void 0
};
},
async serve(event, pathname) {
pathname = decodeURIComponent(pathname);
const object = await bucket.get(pathname);
if (!object) {
throw createError({ message: "File not found", statusCode: 404 });
}
setHeader(event, "Content-Type", object.httpMetadata?.contentType || getContentType(pathname));
setHeader(event, "Content-Length", object.size);
setHeader(event, "etag", object.httpEtag);
return object.body;
},
async get(pathname) {
const object = await bucket.get(decodeURIComponent(pathname));
if (!object) {
return null;
}
return object.blob();
},
async put(pathname, body, options = {}) {
pathname = decodeURIComponent(pathname);
const { contentType: optionsContentType, contentLength, addRandomSuffix, prefix, customMetadata } = 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(/^\/+/, "");
}
const httpMetadata = { contentType };
if (contentLength) {
httpMetadata.contentLength = contentLength;
}
const object = await bucket.put(pathname, body, { httpMetadata, customMetadata });
return mapR2ObjectToBlob(object);
},
async head(pathname) {
const object = await bucket.head(decodeURIComponent(pathname));
if (!object) {
throw createError({ message: "Blob not found", statusCode: 404 });
}
return mapR2ObjectToBlob(object);
},
async del(pathnames) {
if (Array.isArray(pathnames)) {
return await bucket.delete(pathnames.map((p) => decodeURIComponent(p)));
} else {
return await bucket.delete(decodeURIComponent(pathnames));
}
},
async createMultipartUpload(pathname, options = {}) {
pathname = decodeURIComponent(pathname);
const { contentType: optionsContentType, contentLength, addRandomSuffix, prefix, customMetadata } = 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(/^\/+/, "");
}
const httpMetadata = { contentType };
if (contentLength) {
httpMetadata.contentLength = contentLength;
}
const mpu = await bucket.createMultipartUpload(pathname, { httpMetadata, customMetadata });
return mapR2MpuToBlobMpu(mpu);
},
resumeMultipartUpload(pathname, uploadId) {
const mpu = bucket.resumeMultipartUpload(decodeURIComponent(pathname), uploadId);
return mapR2MpuToBlobMpu(mpu);
},
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;
},
async createCredentials(options = {}) {
if (import.meta.dev) {
throw createError("hubBlob().createCredentials() is only available in production or in development with `--remote` flag.");
}
if (!process.env.NUXT_HUB_PROJECT_DEPLOY_TOKEN) {
throw createError("Missing `NUXT_HUB_PROJECT_DEPLOY_TOKEN` environment variable, make sure to deploy with `npx nuxthub deploy` or with the NuxtHub Admin.");
}
const env = process.env.NUXT_HUB_ENV || hub.env || "production";
return await $fetch(`/api/projects/${hub.projectKey}/blob/${env}/credentials`, {
baseURL: hub.url,
method: "POST",
body: options,
headers: {
authorization: `Bearer ${process.env.NUXT_HUB_PROJECT_DEPLOY_TOKEN}`
}
});
}
};
return {
...blob,
delete: blob.del,
handleMultipartUpload: createMultipartUploadHandler(blob)
};
}
export function proxyHubBlob(projectUrl, secretKey, headers) {
requireNuxtHubFeature("blob");
const blobAPI = ofetch.create({
baseURL: joinURL(projectUrl, "/api/_hub/blob"),
headers: {
Authorization: `Bearer ${secretKey}`,
...headers
}
});
const blob = {
async list(options = { limit: 1e3 }) {
return blobAPI("/", {
method: "GET",
query: options
});
},
async serve(_event, pathname) {
return blobAPI(encodeURIComponent(pathname), {
method: "GET"
});
},
async put(pathname, body, options = {}) {
const { contentType, contentLength, ...query } = options;
const headers2 = {};
if (contentType) {
headers2["content-type"] = contentType;
}
if (contentLength) {
headers2["content-length"] = contentLength;
}
if (body instanceof Uint8Array) {
body = new Blob([body]);
}
return await blobAPI(encodeURIComponent(pathname), {
method: "PUT",
headers: headers2,
body,
query
});
},
async head(pathname) {
return await blobAPI(`/head/${encodeURIComponent(pathname)}`, {
method: "GET"
});
},
async get(pathname) {
return await blobAPI(`/${encodeURIComponent(pathname)}`, {
method: "GET",
responseType: "blob"
}).catch((e) => {
if (e.status === 404) {
return null;
}
throw e;
});
},
async del(pathnames) {
if (Array.isArray(pathnames)) {
await blobAPI("/delete", {
method: "POST",
body: {
pathnames: pathnames.map((p) => encodeURIComponent(p))
}
});
} else {
await blobAPI(encodeURIComponent(pathnames), {
method: "DELETE"
});
}
return;
},
async createMultipartUpload(pathname, options = {}) {
return await blobAPI(`/multipart/create/${encodeURIComponent(pathname)}`, {
method: "POST",
query: options
});
},
resumeMultipartUpload(pathname, uploadId) {
return {
pathname,
uploadId,
async uploadPart(partNumber, body) {
return await blobAPI(`/multipart/upload/${encodeURIComponent(pathname)}`, {
method: "PUT",
query: {
uploadId,
partNumber
},
body
});
},
async abort() {
await blobAPI(`/multipart/abort/${encodeURIComponent(pathname)}`, {
method: "DELETE",
query: {
uploadId
}
});
},
async complete(parts) {
return await blobAPI(`/multipart/complete/${encodeURIComponent(pathname)}`, {
method: "POST",
query: {
uploadId
},
body: {
parts
}
});
}
};
},
async handleUpload(event, options = {}) {
return await blobAPI("/", {
method: "POST",
body: await readFormData(event),
query: options
});
},
async createCredentials(options = {}) {
return await blobAPI("/credentials", {
method: "POST",
body: options
});
}
};
return {
...blob,
delete: blob.del,
handleMultipartUpload: createMultipartUploadHandler(blob)
};
}
function createMultipartUploadHandler(hub) {
const { createMultipartUpload, resumeMultipartUpload } = hub;
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 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 = 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 = 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 = 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;
};
}
function getContentType(pathOrExtension) {
return pathOrExtension && mime.getType(pathOrExtension) || "application/octet-stream";
}
function mapR2ObjectToBlob(object) {
return {
pathname: object.key,
contentType: object.httpMetadata?.contentType || getContentType(object.key),
size: object.size,
httpEtag: object.httpEtag,
uploadedAt: object.uploaded,
httpMetadata: object.httpMetadata || {},
customMetadata: object.customMetadata || {}
};
}
function mapR2MpuToBlobMpu(mpu) {
return {
pathname: mpu.key,
uploadId: mpu.uploadId,
async uploadPart(partNumber, value) {
return await mpu.uploadPart(partNumber, value);
},
abort: mpu.abort,
async complete(uploadedParts) {
const object = await mpu.complete(uploadedParts);
return mapR2ObjectToBlob(object);
}
};
}
const FILESIZE_UNITS = ["B", "KB", "MB", "GB"];
function fileSizeToBytes(input) {
const regex = new RegExp(
`^(\\d+)(\\.\\d+)?\\s*(${FILESIZE_UNITS.join("|")})$`,
"i"
);
const match = input.match(regex);
if (!match) {
throw createError({ statusCode: 400, message: `Invalid file size format: ${input}` });
}
const sizeValue = Number.parseFloat(match[1]);
const sizeUnit = match[3].toUpperCase();
if (!FILESIZE_UNITS.includes(sizeUnit)) {
throw createError({ statusCode: 400, message: `Invalid file size unit: ${sizeUnit}` });
}
const bytes = sizeValue * Math.pow(1024, FILESIZE_UNITS.indexOf(sizeUnit));
return Math.floor(bytes);
}
export function ensureBlob(blob, options = {}) {
requireNuxtHubFeature("blob");
if (!options.maxSize && !options.types?.length) {
throw createError({
statusCode: 400,
message: "ensureBlob() requires at least one of maxSize or types to be set."
});
}
if (options.maxSize) {
const maxFileSizeBytes = fileSizeToBytes(options.maxSize);
if (blob.size > maxFileSizeBytes) {
throw createError({
statusCode: 400,
message: `File size must be less than ${options.maxSize}`
});
}
}
const blobShortType = blob.type.split("/")[0];
if (options.types?.length && !options.types.includes(blob.type) && !options.types.includes(blobShortType) && !(options.types.includes("pdf") && blob.type === "application/pdf")) {
throw createError({
statusCode: 400,
message: `File type is invalid, must be: ${options.types.join(", ")}`
});
}
}