JavaScript library for direct file uploads to cloud storage services in Rails applications
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Controller classes for managing upload workflows, event dispatching, and coordinating multiple file uploads within forms.
Controller for managing individual file uploads with event dispatching and DOM integration.
/**
* Controller for managing individual file uploads with event dispatching
* Handles DOM events, hidden input creation, and upload coordination
*/
class DirectUploadController {
/**
* Creates a new DirectUploadController
* @param input - File input element containing the file
* @param file - File object to upload
*/
constructor(input: HTMLInputElement, file: File);
/**
* Starts the upload process and creates hidden form input
* @param callback - Called when upload completes or fails
*/
start(callback: (error: string | null) => void): void;
/**
* Handles upload progress events
* @param event - Progress event from XMLHttpRequest
*/
uploadRequestDidProgress(event: ProgressEvent): void;
/** Direct upload URL extracted from input's data-direct-upload-url attribute */
readonly url: string;
/** File input element */
readonly input: HTMLInputElement;
/** File being uploaded */
readonly file: File;
/** Underlying DirectUpload instance */
readonly directUpload: DirectUpload;
}Usage Examples:
import { DirectUploadController } from "@rails/activestorage";
// Manual controller usage
const fileInput = document.querySelector("input[type=file]");
const file = fileInput.files[0];
const controller = new DirectUploadController(fileInput, file);
controller.start((error) => {
if (error) {
console.error("Upload failed:", error);
} else {
console.log("Upload completed successfully");
// Hidden input with signed_id has been created
}
});
// Listen for controller events
fileInput.addEventListener("direct-upload:progress", (event) => {
const { progress, file } = event.detail;
console.log(`${file.name}: ${Math.round(progress)}%`);
});
fileInput.addEventListener("direct-upload:error", (event) => {
const { error, file } = event.detail;
console.error(`Failed to upload ${file.name}:`, error);
});Controller for managing multiple file uploads within a single form, coordinating sequential uploads and form submission.
/**
* Controller for managing multiple file uploads in a form
* Coordinates sequential uploads and handles form submission
*/
class DirectUploadsController {
/**
* Creates a new DirectUploadsController
* Automatically finds all file inputs with data-direct-upload-url in the form
* @param form - Form element containing file inputs
*/
constructor(form: HTMLFormElement);
/**
* Starts uploading all files sequentially
* @param callback - Called when all uploads complete or first error occurs
*/
start(callback: (error?: string) => void): void;
/**
* Creates DirectUploadController instances for all files
* @returns Array of DirectUploadController instances
*/
createDirectUploadControllers(): DirectUploadController[];
/** Form element being managed */
readonly form: HTMLFormElement;
/** Array of file input elements with files selected */
readonly inputs: HTMLInputElement[];
}Usage Examples:
import { DirectUploadsController } from "@rails/activestorage";
// Manual form upload management
const form = document.querySelector("form");
const controller = new DirectUploadsController(form);
// Start uploads for all files in form
controller.start((error) => {
if (error) {
console.error("Upload failed:", error);
// Re-enable form inputs
enableFormInputs(form);
} else {
console.log("All uploads completed");
// Form can now be submitted normally
form.submit();
}
});
// Listen for form-level events
form.addEventListener("direct-uploads:start", () => {
console.log("Starting uploads...");
showLoadingSpinner();
});
form.addEventListener("direct-uploads:end", () => {
console.log("All uploads completed");
hideLoadingSpinner();
});Both controller classes dispatch custom DOM events to provide upload progress and status information.
DirectUploadController Events (dispatched on input element):
interface DirectUploadControllerEvents {
/** Dispatched when controller is initialized */
"direct-upload:initialize": {
detail: { id: number; file: File };
};
/** Dispatched when upload starts */
"direct-upload:start": {
detail: { id: number; file: File };
};
/** Dispatched before blob creation request */
"direct-upload:before-blob-request": {
detail: { id: number; file: File; xhr: XMLHttpRequest };
};
/** Dispatched before file storage request */
"direct-upload:before-storage-request": {
detail: { id: number; file: File; xhr: XMLHttpRequest };
};
/** Dispatched during upload progress */
"direct-upload:progress": {
detail: { id: number; file: File; progress: number };
};
/** Dispatched when upload error occurs */
"direct-upload:error": {
detail: { id: number; file: File; error: string };
};
/** Dispatched when upload completes */
"direct-upload:end": {
detail: { id: number; file: File };
};
}DirectUploadsController Events (dispatched on form element):
interface DirectUploadsControllerEvents {
/** Dispatched when upload process begins */
"direct-uploads:start": {
detail: {};
};
/** Dispatched when all uploads complete */
"direct-uploads:end": {
detail: {};
};
}Event Handling Examples:
// Track individual file progress
document.addEventListener("direct-upload:progress", (event) => {
const { id, file, progress } = event.detail;
updateFileProgress(id, file.name, progress);
});
// Handle upload errors with custom UI
document.addEventListener("direct-upload:error", (event) => {
const { file, error } = event.detail;
// Prevent default alert
event.preventDefault();
// Show custom error message
showErrorToast(`Failed to upload ${file.name}: ${error}`);
});
// Modify requests before they're sent
document.addEventListener("direct-upload:before-blob-request", (event) => {
const { xhr } = event.detail;
xhr.setRequestHeader("X-Custom-Header", "value");
});
// Track upload bandwidth
document.addEventListener("direct-upload:before-storage-request", (event) => {
const { xhr, file } = event.detail;
const startTime = Date.now();
xhr.upload.addEventListener("progress", (progressEvent) => {
const elapsed = Date.now() - startTime;
const loaded = progressEvent.loaded;
const speed = loaded / elapsed * 1000; // bytes per second
console.log(`${file.name} upload speed: ${formatBytes(speed)}/s`);
});
});DirectUploadController automatically creates hidden form inputs containing the signed blob IDs for successful uploads.
Hidden Input Creation:
// When upload completes successfully, controller creates:
// <input type="hidden" name="original_input_name" value="signed_blob_id">
// For example, if original input was:
// <input type="file" name="post[attachments][]" data-direct-upload-url="...">
// Controller creates:
// <input type="hidden" name="post[attachments][]" value="eyJfcmFpbHMiOnsibWVzc2F...">Manual Hidden Input Handling:
import { DirectUploadController } from "@rails/activestorage";
class CustomDirectUploadController extends DirectUploadController {
start(callback) {
// Override to customize hidden input creation
super.start((error) => {
if (!error) {
// Custom logic after successful upload
this.createCustomHiddenInput();
}
callback(error);
});
}
createCustomHiddenInput() {
const hiddenInput = document.createElement("input");
hiddenInput.type = "hidden";
hiddenInput.name = "custom_attachment_ids[]";
hiddenInput.value = this.directUpload.blob.signed_id;
hiddenInput.dataset.filename = this.file.name;
hiddenInput.dataset.contentType = this.file.type;
this.input.parentNode.insertBefore(hiddenInput, this.input);
}
}Controllers provide comprehensive error handling with customizable recovery options.
Error Types:
// Network errors
"Error creating Blob for \"file.jpg\". Status: 422"
"Error storing \"file.jpg\". Status: 403"
// File system errors
"Error reading file.jpg"
// Server errors
"Error creating Blob for \"file.jpg\". Status: 500"Custom Error Handling:
class RobustDirectUploadsController extends DirectUploadsController {
start(callback) {
let retryCount = 0;
const maxRetries = 3;
const attemptUpload = () => {
super.start((error) => {
if (error && retryCount < maxRetries) {
retryCount++;
console.log(`Upload failed, retrying... (${retryCount}/${maxRetries})`);
setTimeout(attemptUpload, 1000 * retryCount); // Exponential backoff
} else {
callback(error);
}
});
};
attemptUpload();
}
}
// Usage
const robustController = new RobustDirectUploadsController(form);
robustController.start((error) => {
if (error) {
console.error("Upload failed after retries:", error);
} else {
console.log("Upload successful");
}
});DirectUploadsController uploads files sequentially rather than in parallel to avoid overwhelming the server and provide predictable progress tracking.
Upload Sequence:
// Files are uploaded one at a time in this order:
// 1. File A: checksum → blob creation → storage upload
// 2. File B: checksum → blob creation → storage upload
// 3. File C: checksum → blob creation → storage upload
// 4. All complete → callback with no error
// If any file fails, remaining files are not uploaded
// and callback is called immediately with errorCustom Parallel Upload Controller:
class ParallelDirectUploadsController extends DirectUploadsController {
start(callback) {
const controllers = this.createDirectUploadControllers();
const results = [];
let completedCount = 0;
let hasError = false;
this.dispatch("start");
controllers.forEach((controller, index) => {
controller.start((error) => {
if (hasError) return; // Skip if already failed
if (error) {
hasError = true;
callback(error);
this.dispatch("end");
return;
}
results[index] = true;
completedCount++;
if (completedCount === controllers.length) {
callback();
this.dispatch("end");
}
});
});
// Handle case where no controllers exist
if (controllers.length === 0) {
callback();
this.dispatch("end");
}
}
}Install with Tessl CLI
npx tessl i tessl/npm-rails--activestorage