-
Notifications
You must be signed in to change notification settings - Fork 0
FILES MODULE
Complete file management on the front-end: validation, native picker selection, upload with progress, download (URL and Blob), preview, and tabular export (CSV, PDF, Excel). Package:
@fireflyframework/core· EXTENDED Module #1 · Platform + Embedded
- 1. Overview
- 2. Developer Usage Guide
- 3. API Reference
- 4. Internal Technical Architecture
- 5. Advanced Usage Patterns
The Files module groups six services that cover the complete lifecycle of a file in a front-end application: validate, select, upload, download, preview, and export.
- FileValidationService — validates files against configurable rules (size, MIME type, extension) before processing them. Supports per-call rules and module-level defaults
-
FilePickerService — opens the browser's native file picker programmatically, returning a
Promise<File[]>. Includes apickImage()shortcut for image selection -
FileUploadService — uploads files via HTTP POST with real-time progress tracking (
Observable<UploadProgress>). Supports single and concurrent multiple uploads - FileDownloadService — downloads files from a URL or from an in-memory Blob using the hidden anchor technique. Automatically revokes blob URLs to prevent memory leaks
- FilePreviewService — manages the lifecycle of blob URLs for preview. Opens previews in a new tab and tracks active URLs for cleanup
-
FileExportService — exports tabular data (arrays of objects) to CSV, PDF, or Excel. Generates the Blob and delegates download to
FileDownloadService
- It does not implement dropzone UI, progress bars, or preview modals — that is the responsibility of the product layer
- It does not manage persistent server-side storage — it only operates in the browser
- It does not implement chunked or resumable uploads — uploads use standard HTTP POST
- It does not generate complex PDFs with advanced styling — PDF export is basic tabular
- It does not handle image compression or thumbnails
- It does not implement drag-and-drop — products use the native picker or their own D&D solution
Every product needs to work with files: upload documents, download reports, export data, validate attachments. Without a unified module, each product reimplements these capabilities with inconsistent patterns, poor memory management (blob URL leaks), and no standardization in validation.
The Files module provides a headless service layer that:
- Standardizes validation (size, type, extension)
- Centralizes configuration via
provideFiles()with sensible defaults - Guarantees memory cleanup (blob URL tracking)
- Exposes reactive APIs (Observables for upload, Promises for picker/export)
EXTENDED module (not CORE): Unlike core modules that register automatically, Files requires an explicit call to provideFiles(). This is because not every application needs file management, and the services depend on HttpClient (upload) and DOCUMENT (picker, download, preview), which only make sense in browser contexts.
The services do NOT use providedIn: 'root' — they are registered via makeEnvironmentProviders when calling provideFiles(). This avoids unnecessary instantiation in products that do not use files.
In the original Flutter portal (soon-distributor-portal), file management was scattered:
-
file_pickerpackage for selection - Manual validation logic on every screen
-
diofor uploads with progress callback -
url_launcherfor downloads -
csvpackage for export
Firefly consolidates everything into a cohesive module with centralized configuration, standardized validation, and automatic memory management, eliminating the fragmentation that existed in Flutter.
The Files module is EXTENDED — it requires explicit registration:
// app.config.ts
import { provideFiles } from '@fireflyframework/core';
export const appConfig: ApplicationConfig = {
providers: [
provideFiles({
maxFileSizeBytes: 5 * 1024 * 1024, // 5 MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'application/pdf'],
maxConcurrentUploads: 3,
enablePreview: true,
}),
],
};All fields are optional. Defaults:
| Field | Default |
|---|---|
maxFileSizeBytes |
10485760 (10 MB) |
allowedMimeTypes |
[] (no restriction) |
maxConcurrentUploads |
5 |
uploadEndpoint |
undefined |
enablePreview |
true |
With no arguments (provideFiles()), the services are registered with defaults.
import { FileValidationService } from '@fireflyframework/core';
@Component({ /* ... */ })
export class UploadComponent {
private readonly validator = inject(FileValidationService);
onFileSelected(file: File): void {
// Uses module defaults (from provideFiles config)
const result = this.validator.validate(file);
if (!result.valid) {
console.error('Errors:', result.errors);
return;
}
// Or use call-specific rules (these take precedence)
const strictResult = this.validator.validate(file, {
maxSizeBytes: 2 * 1024 * 1024,
allowedMimeTypes: ['application/pdf'],
allowedExtensions: ['.pdf'],
});
}
}Rule precedence: rules passed by parameter (FileValidationConfig) take precedence over module defaults (FilesConfig). If no rules are passed, the defaults are applied.
import { FilePickerService } from '@fireflyframework/core';
@Component({ /* ... */ })
export class FileSelectComponent {
private readonly picker = inject(FilePickerService);
async selectDocument(): Promise<void> {
const files = await this.picker.pickFile('application/pdf,.docx', false);
if (files.length > 0) {
console.log('Selected file:', files[0].name);
}
}
async selectMultipleImages(): Promise<void> {
const files = await this.picker.pickFile('image/*', true);
console.log(`${files.length} images selected`);
}
async selectSingleImage(): Promise<void> {
const image = await this.picker.pickImage();
if (image) {
console.log('Image:', image.name, image.size);
}
}
}pickFile() returns an empty array if the user cancels. pickImage() returns null on cancel.
import { FileUploadService, UploadProgress } from '@fireflyframework/core';
@Component({ /* ... */ })
export class UploadComponent {
private readonly uploader = inject(FileUploadService);
progress = signal<UploadProgress | null>(null);
upload(file: File): void {
this.uploader.upload(file, '/api/files').subscribe({
next: (p) => {
this.progress.set(p);
console.log(`${p.percent}% — ${p.loadedBytes}/${p.totalBytes}`);
},
error: (err) => console.error('Upload failed:', err),
});
}
}upload(files: File[]): void {
this.uploader.uploadMultiple(files, '/api/files').subscribe({
next: (progressArray) => {
// progressArray: UploadProgress[] — one per file
progressArray.forEach((p, i) => {
console.log(`File ${i}: ${p.percent}% (${p.status})`);
});
},
complete: () => console.log('All uploads complete'),
});
}this.uploader.upload(file, '/api/files', {
'Authorization': 'Bearer my-token',
'X-Custom-Header': 'value',
}).subscribe(/* ... */);import { FileDownloadService } from '@fireflyframework/core';
@Component({ /* ... */ })
export class DownloadComponent {
private readonly downloader = inject(FileDownloadService);
downloadReport(): void {
this.downloader.download('/api/reports/123', 'report-2024.pdf');
}
}downloadGenerated(): void {
const content = JSON.stringify({ data: 'example' }, null, 2);
const blob = new Blob([content], { type: 'application/json' });
this.downloader.downloadBlob(blob, 'data.json');
}downloadBlob() creates a temporary blob URL, triggers the download, and revokes the URL automatically.
import { FilePreviewService } from '@fireflyframework/core';
@Component({ /* ... */ })
export class PreviewComponent implements OnDestroy {
private readonly preview = inject(FilePreviewService);
previewPdf(blob: Blob): void {
this.preview.previewInNewTab(blob, 'application/pdf');
}
// For use in templates (e.g. <img [src]=imageUrl>)
imageUrl: string | null = null;
showImage(blob: Blob): void {
this.imageUrl = this.preview.createBlobUrl(blob);
}
ngOnDestroy(): void {
// Cleans up all blob URLs when the component is destroyed
this.preview.revokeAll();
}
}previewInNewTab() returns null if enablePreview is disabled in the configuration.
activeUrlCount exposes the number of active blob URLs (useful for debugging memory leaks).
import { FileExportService, ExportConfig } from '@fireflyframework/core';
interface User {
name: string;
email: string;
role: string;
}
@Component({ /* ... */ })
export class ExportComponent {
private readonly exporter = inject(FileExportService);
async exportUsers(users: User[]): Promise<void> {
await this.exporter.export(users, {
format: 'csv',
filename: 'users-report',
columns: [
{ key: 'name', header: 'Full Name' },
{ key: 'email', header: 'Email Address' },
{ key: 'role', header: 'Role' },
],
csvSeparator: ';',
});
}
// Or generate the Blob without downloading (for preview)
async generateExcel(users: User[]): Promise<Blob> {
return this.exporter.generateBlob(users, {
format: 'xlsx',
filename: 'users',
columns: [
{ key: 'name', header: 'Name', width: 30 },
{ key: 'email', header: 'Email', width: 40 },
],
title: 'User Report',
sheetName: 'Users',
});
}
}Supported formats: csv, pdf, xlsx.
Module-level configuration, passed to provideFiles().
interface FilesConfig {
readonly maxFileSizeBytes: number;
readonly allowedMimeTypes: string[];
readonly maxConcurrentUploads: number;
readonly uploadEndpoint?: string;
readonly enablePreview: boolean;
}| Field | Type | Default | Description |
|---|---|---|---|
maxFileSizeBytes |
number |
10485760 |
Maximum size in bytes |
allowedMimeTypes |
string[] |
[] |
Allowed MIME types. Empty array = no restriction |
maxConcurrentUploads |
number |
5 |
Maximum simultaneous uploads |
uploadEndpoint |
string? |
undefined |
Default upload endpoint |
enablePreview |
boolean |
true |
Enables preview |
Per-call validation rules.
interface FileValidationConfig {
readonly maxSizeBytes?: number;
readonly allowedMimeTypes?: string[];
readonly allowedExtensions?: string[];
}| Field | Type | Description |
|---|---|---|
maxSizeBytes |
number? |
Maximum size in bytes |
allowedMimeTypes |
string[]? |
MIME types (supports wildcards: image/*) |
allowedExtensions |
string[]? |
Extensions with the dot (.pdf, .png) |
interface ValidationResult {
readonly valid: boolean;
readonly errors: string[];
}Emitted by FileUploadService.upload() as Observable<UploadProgress>.
interface UploadProgress {
readonly percent: number; // 0-100
readonly totalBytes?: number; // undefined if the server does not report
readonly loadedBytes: number;
readonly status: 'pending' | 'uploading' | 'complete' | 'error';
readonly response?: unknown; // only when status === 'complete'
readonly error?: string; // only when status === 'error'
}type ExportFormat = 'csv' | 'pdf' | 'xlsx';interface ExportColumn<T = unknown> {
readonly key: keyof T & string;
readonly header: string;
readonly width?: number; // chars (CSV), pts (PDF/Excel)
}interface ExportConfig<T = unknown> {
readonly format: ExportFormat;
readonly filename: string; // without extension
readonly columns: ExportColumn<T>[];
readonly title?: string; // header in PDF, title in Excel
readonly sheetName?: string; // default: 'Sheet1'
readonly csvSeparator?: string; // default: ','
}Validates files against configurable rules. Checks MIME type, size, and extension.
class FileValidationService {
validate(file: File, rules?: FileValidationConfig): ValidationResult;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
validate |
file: File, rules?: FileValidationConfig
|
ValidationResult |
Validates the file. Without rules, uses module defaults |
Fallback behavior: if no rules are provided, the service uses maxFileSizeBytes and allowedMimeTypes from FilesConfig. Per-call rules always take precedence.
Wildcard support in MIME: image/* matches any image/png, image/jpeg, etc.
Opens the browser's native file picker. Creates a hidden <input type=file>, clicks it, and resolves with the selected files. The input is removed from the DOM afterwards.
class FilePickerService {
pickFile(accept?: string, multiple?: boolean): Promise<File[]>;
pickImage(): Promise<File | null>;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
pickFile |
accept?: string, multiple?: boolean
|
Promise<File[]> |
Opens the picker. Returns [] on cancel |
pickImage |
— | Promise<File | null> |
Shorthand for pickFile('image/*', false)
|
Uploads files via HTTP POST with progress. Uses HttpClient with reportProgress: true.
class FileUploadService {
upload(file: File, endpoint: string, headers?: Record<string, string>): Observable<UploadProgress>;
uploadMultiple(files: File[], endpoint: string): Observable<UploadProgress[]>;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
upload |
file, endpoint, headers?
|
Observable<UploadProgress> |
Uploads a file with progress |
uploadMultiple |
files, endpoint
|
Observable<UploadProgress[]> |
Uploads multiple files, emits an array of progress |
The upload uses FormData internally. The endpoint is required (there is no implicit fallback to uploadEndpoint from the config in the current public API).
Downloads files using the hidden anchor technique (<a download>).
class FileDownloadService {
download(url: string, filename: string): void;
downloadBlob(blob: Blob, filename: string): void;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
download |
url, filename
|
void |
Downloads from a URL |
downloadBlob |
blob, filename
|
void |
Downloads an in-memory Blob. Revokes the blob URL automatically |
Manages blob URLs for preview. Tracks active URLs for cleanup.
class FilePreviewService {
createBlobUrl(blob: Blob): string;
revokeBlobUrl(url: string): void;
revokeAll(): void;
previewInNewTab(blob: Blob, mimeType?: string): string | null;
get activeUrlCount(): number;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
createBlobUrl |
blob |
string |
Creates a blob URL and tracks it |
revokeBlobUrl |
url |
void |
Revokes a blob URL |
revokeAll |
— | void |
Revokes all tracked URLs |
previewInNewTab |
blob, mimeType?
|
string | null |
Opens preview in a tab. null if preview is disabled |
activeUrlCount |
— | number |
Number of active URLs (getter) |
previewInNewTab() returns null (no-op) when enablePreview === false in FilesConfig.
Exports tabular data to a file. Selects the exporter according to the format and delegates the download to FileDownloadService.
class FileExportService {
export<T>(data: T[], config: ExportConfig<T>): Promise<void>;
generateBlob<T>(data: T[], config: ExportConfig<T>): Promise<Blob>;
}| Method | Parameters | Returns | Description |
|---|---|---|---|
export |
data, config
|
Promise<void> |
Generates the file and triggers the download |
generateBlob |
data, config
|
Promise<Blob> |
Only generates the Blob (no download) |
generateBlob() is useful for previewing the export or for additional processing before downloading.
function provideFiles(config?: Partial<FilesConfig>): EnvironmentProviders;Registers all services of the Files module and optionally configures FilesConfig.
- Without arguments: uses sensible defaults
- With
Partial<FilesConfig>: merges with the defaults
EXTENDED pattern — does not use providedIn: 'root'. The services are only available if provideFiles() is called.
const FILES_CONFIG: InjectionToken<FilesConfig>;Injection token for the module's resolved configuration. Injectable in any component or service after registering provideFiles().
private readonly config = inject(FILES_CONFIG);
console.log(this.config.maxFileSizeBytes); // resolved value- Headless — the services have no UI. They provide pure operations that the product layer consumes to build its own UI
-
Centralized configuration —
FilesConfigdefines module-level defaults. The services read the token so the product does not have to pass config on every call -
Memory safety —
FilePreviewServicetracks every blob URL and exposesrevokeAll()for cleanup.FileDownloadService.downloadBlob()revokes automatically - Observable for I/O, Promise for actions — upload emits progress via an Observable (stream). Picker and export use Promise (one-shot action)
- No global state — the services do not store files or a history of operations. State lives in the component that consumes them
libs/framework-core/src/lib/files/
index.ts # barrel export
files.provider.ts # provideFiles(), FILES_CONFIG
files.config.ts # FilesConfig interface, defaults
services/
file-validation.service.ts # FileValidationService
file-picker.service.ts # FilePickerService
file-upload.service.ts # FileUploadService
file-download.service.ts # FileDownloadService
file-preview.service.ts # FilePreviewService
file-export.service.ts # FileExportService
types/
upload-progress.ts # UploadProgress
validation-result.ts # ValidationResult, FileValidationConfig
export-config.ts # ExportConfig, ExportColumn, ExportFormat
[Product component]
|
|--- pickFile() ---> FilePickerService ---> <input type=file> ---> File[]
|
|--- validate(file) ---> FileValidationService ---> ValidationResult
| |
| +--- reads FilesConfig (fallback)
|
|--- upload(file, endpoint) ---> FileUploadService ---> HttpClient POST
| | (reportProgress)
| +--- Observable<UploadProgress>
|
|--- download(url, name) ---> FileDownloadService ---> <a download> click
|--- downloadBlob(blob) ---> FileDownloadService ---> blob URL + <a> + revoke
|
|--- previewInNewTab(blob) ---> FilePreviewService ---> window.open(blobUrl)
|--- createBlobUrl(blob) ---> FilePreviewService ---> URL.createObjectURL
|
|--- export(data, config) ---> FileExportService ---> generateBlob()
| |
+--- FileDownloadService.downloadBlob()
| Service | Injects | Reason |
|---|---|---|
FileValidationService |
FILES_CONFIG |
Reads validation defaults |
FilePickerService |
DOCUMENT |
Creates <input> in the DOM |
FileUploadService |
HttpClient, FILES_CONFIG
|
POST with progress, reads config |
FileDownloadService |
DOCUMENT |
Creates <a> in the DOM |
FilePreviewService |
DOCUMENT, FILES_CONFIG
|
URL.createObjectURL, reads enablePreview
|
FileExportService |
FileDownloadService |
Delegates download of the generated blob |
External dependencies of the module:
-
@angular/core—inject,InjectionToken,makeEnvironmentProviders -
@angular/common—DOCUMENT -
@angular/common/http—HttpClient(onlyFileUploadService) -
rxjs—Observable(onlyFileUploadService)
| Decision | Discarded alternative | Reason |
|---|---|---|
| EXTENDED module (not CORE) |
providedIn: 'root' on every service |
Not every app needs files. Avoids unnecessary instantiation |
makeEnvironmentProviders |
NgModule with forRoot()
|
Consistent with modern standalone Angular. No legacy modules |
| Observable for upload | Promise with callback | Upload has a natural progress stream. Observable enables RxJS operators (retry, cancellation) |
| Promise for picker | Observable | Picker is one-shot: the user selects and that's it. Promise is more natural |
<a download> for downloading |
fetch + streaming |
Maximum cross-browser compatibility. No CORS required for same origin |
| Blob URL tracking in preview | Leave cleanup to the product | Memory leaks are a real problem. The service guarantees cleanup |
| No global state | Centralized file store | Files are ephemeral on the front-end. Does not justify a permanent store |
| Module | Relationship |
|---|---|
| Error Handling | The product can use ErrorService to report upload/validation errors. Files does not do it automatically — the error is handled by the component |
| Environment | The product can build upload/download endpoints using EnvironmentService.resolve(). Files does not depend directly on Environment |
| I18n | The error messages in ValidationResult.errors are in English (technical strings). Translation is the product's responsibility |
| Alerts | The product can use AlertService to display success/error toasts after upload/download. Files does not emit alerts |
| Auth | If the upload endpoint requires authentication, the product passes the token via headers in upload(). The global authInterceptor also applies if it is registered |
| Security | Files does not sanitize file contents. If the product displays downloaded HTML content, it must use sanitizeHtml() from the Security module |
async uploadWithValidation(file: File): Promise<void> {
const result = this.validator.validate(file);
if (!result.valid) {
this.alerts.error(result.errors.join(', '));
return;
}
this.uploader.upload(file, '/api/files').subscribe({
next: (p) => this.progress.set(p),
error: (err) => this.alerts.error('Upload failed'),
complete: () => this.alerts.success('File uploaded'),
});
}async selectAndUpload(): Promise<void> {
const files = await this.picker.pickFile('application/pdf', true);
if (files.length === 0) return;
const valid = files.every(f => this.validator.validate(f).valid);
if (!valid) {
this.alerts.error('Some files are invalid');
return;
}
this.uploader.uploadMultiple(files, '/api/files').subscribe({
next: (progress) => this.multiProgress.set(progress),
complete: () => this.alerts.success(`${files.length} files uploaded`),
});
}async previewBeforeExport(data: User[]): Promise<void> {
const blob = await this.exporter.generateBlob(data, {
format: 'pdf',
filename: 'report',
columns: [
{ key: 'name', header: 'Name' },
{ key: 'email', header: 'Email' },
],
});
// Preview first
this.preview.previewInNewTab(blob, 'application/pdf');
// Then the user decides whether to download
// this.downloader.downloadBlob(blob, 'report.pdf');
}async downloadFromApi(fileId: string): Promise<void> {
const url = this.env.resolve(`/api/files/${fileId}`);
// If you need control over headers/response
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${this.token}` },
});
const blob = await response.blob();
const filename = response.headers.get('Content-Disposition')
?.split('filename=')[1] ?? 'download';
this.downloader.downloadBlob(blob, filename);
}private readonly config = inject(FILES_CONFIG);
logConfig(): void {
console.log('Max size:', this.config.maxFileSizeBytes);
console.log('Allowed types:', this.config.allowedMimeTypes);
console.log('Max concurrent:', this.config.maxConcurrentUploads);
console.log('Preview enabled:', this.config.enablePreview);
}@Component({ /* ... */ })
export class GalleryComponent implements OnDestroy {
private readonly preview = inject(FilePreviewService);
imageUrls: string[] = [];
addImage(blob: Blob): void {
this.imageUrls.push(this.preview.createBlobUrl(blob));
}
ngOnDestroy(): void {
this.preview.revokeAll();
// Or individually:
// this.imageUrls.forEach(url => this.preview.revokeBlobUrl(url));
}
}- Alerts Module