@ackplus/nest-file-storage 1.1.22 → 2.0.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/MIGRATION.md +220 -0
  3. package/README.md +353 -547
  4. package/dist/index.d.ts +1 -4
  5. package/dist/index.js +1 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/constants.d.ts +3 -1
  8. package/dist/lib/constants.js +4 -2
  9. package/dist/lib/constants.js.map +1 -1
  10. package/dist/lib/driver-registry.d.ts +34 -0
  11. package/dist/lib/driver-registry.js +118 -0
  12. package/dist/lib/driver-registry.js.map +1 -0
  13. package/dist/lib/drivers/azure.driver.d.ts +21 -0
  14. package/dist/lib/drivers/azure.driver.js +91 -0
  15. package/dist/lib/drivers/azure.driver.js.map +1 -0
  16. package/dist/lib/drivers/driver.interface.d.ts +40 -0
  17. package/dist/lib/drivers/driver.interface.js +3 -0
  18. package/dist/lib/drivers/driver.interface.js.map +1 -0
  19. package/dist/lib/drivers/driver.util.d.ts +2 -0
  20. package/dist/lib/drivers/driver.util.js +15 -0
  21. package/dist/lib/drivers/driver.util.js.map +1 -0
  22. package/dist/lib/drivers/index.d.ts +7 -0
  23. package/dist/lib/drivers/index.js +39 -0
  24. package/dist/lib/drivers/index.js.map +1 -0
  25. package/dist/lib/drivers/local.driver.d.ts +15 -0
  26. package/dist/lib/drivers/local.driver.js +110 -0
  27. package/dist/lib/drivers/local.driver.js.map +1 -0
  28. package/dist/lib/drivers/s3.driver.d.ts +22 -0
  29. package/dist/lib/drivers/s3.driver.js +103 -0
  30. package/dist/lib/drivers/s3.driver.js.map +1 -0
  31. package/dist/lib/file-storage.service.d.ts +16 -5
  32. package/dist/lib/file-storage.service.js +60 -22
  33. package/dist/lib/file-storage.service.js.map +1 -1
  34. package/dist/lib/index.d.ts +9 -2
  35. package/dist/lib/index.js +15 -2
  36. package/dist/lib/index.js.map +1 -1
  37. package/dist/lib/interceptor/file-storage.interceptor.d.ts +7 -10
  38. package/dist/lib/interceptor/file-storage.interceptor.js +119 -112
  39. package/dist/lib/interceptor/file-storage.interceptor.js.map +1 -1
  40. package/dist/lib/multer/driver-multer-engine.d.ts +18 -0
  41. package/dist/lib/multer/driver-multer-engine.js +91 -0
  42. package/dist/lib/multer/driver-multer-engine.js.map +1 -0
  43. package/dist/lib/nest-file-storage.module.d.ts +3 -3
  44. package/dist/lib/nest-file-storage.module.js +81 -44
  45. package/dist/lib/nest-file-storage.module.js.map +1 -1
  46. package/dist/lib/registry-holder.d.ts +6 -0
  47. package/dist/lib/registry-holder.js +26 -0
  48. package/dist/lib/registry-holder.js.map +1 -0
  49. package/dist/lib/tenant/tenant-from.d.ts +14 -0
  50. package/dist/lib/tenant/tenant-from.js +71 -0
  51. package/dist/lib/tenant/tenant-from.js.map +1 -0
  52. package/dist/lib/tenant/tenant.types.d.ts +20 -0
  53. package/dist/lib/tenant/tenant.types.js +3 -0
  54. package/dist/lib/tenant/tenant.types.js.map +1 -0
  55. package/dist/lib/types.d.ts +45 -35
  56. package/dist/lib/types.js.map +1 -1
  57. package/dist/lib/validation.d.ts +22 -0
  58. package/dist/lib/validation.js +98 -0
  59. package/dist/lib/validation.js.map +1 -0
  60. package/dist/tsconfig.build.tsbuildinfo +1 -1
  61. package/examples/1-basic-local-storage.example.ts +11 -7
  62. package/examples/10-testing.example.ts +60 -196
  63. package/examples/11-custom-driver.example.ts +82 -0
  64. package/examples/12-multi-tenant.example.ts +93 -0
  65. package/examples/2-s3-storage.example.ts +18 -16
  66. package/examples/3-azure-storage.example.ts +14 -12
  67. package/examples/4-upload-controller.example.ts +20 -55
  68. package/examples/5-custom-configuration.example.ts +37 -57
  69. package/examples/6-file-service.example.ts +34 -91
  70. package/examples/7-user-avatar.example.ts +37 -92
  71. package/examples/8-document-management.example.ts +45 -196
  72. package/examples/9-dynamic-storage.example.ts +29 -147
  73. package/examples/README.md +25 -107
  74. package/package.json +17 -4
  75. package/dist/lib/storage/azure.storage.d.ts +0 -18
  76. package/dist/lib/storage/azure.storage.js +0 -210
  77. package/dist/lib/storage/azure.storage.js.map +0 -1
  78. package/dist/lib/storage/local.storage.d.ts +0 -20
  79. package/dist/lib/storage/local.storage.js +0 -212
  80. package/dist/lib/storage/local.storage.js.map +0 -1
  81. package/dist/lib/storage/s3.storage.d.ts +0 -19
  82. package/dist/lib/storage/s3.storage.js +0 -241
  83. package/dist/lib/storage/s3.storage.js.map +0 -1
  84. package/dist/lib/storage.factory.d.ts +0 -8
  85. package/dist/lib/storage.factory.js +0 -46
  86. package/dist/lib/storage.factory.js.map +0 -1
package/README.md CHANGED
@@ -1,105 +1,66 @@
1
1
  # @ackplus/nest-file-storage
2
2
 
3
- A flexible and feature-rich file storage solution for NestJS applications with support for Local, AWS S3, and Azure Blob Storage.
3
+ > One file-storage API for NestJS Local, S3, Azure, or your own driver. Upload through a controller interceptor or call it directly. Pick storage **per request** (great for multi-tenant apps), validate declaratively, and store one consistent file shape everywhere.
4
4
 
5
- ## ✨ Features
5
+ [![npm](https://img.shields.io/npm/v/@ackplus/nest-file-storage.svg)](https://www.npmjs.com/package/@ackplus/nest-file-storage)
6
+ [![license](https://img.shields.io/npm/l/@ackplus/nest-file-storage.svg)](./LICENSE)
6
7
 
7
- - 📦 **Multiple Storage Providers** - Local, AWS S3, and Azure Blob Storage support
8
- - 🔄 **Easy Switching** - Switch between storage providers with minimal configuration
9
- - 🎯 **NestJS Integration** - Seamless integration with NestJS decorators and interceptors
10
- - 📁 **File Operations** - Upload, download, delete, copy files with ease
11
- - 🔐 **Signed URLs** - Generate presigned URLs for secure file access (S3)
12
- - 🎨 **Customizable** - Custom file naming, directory structure, and transformations
13
- - 📝 **TypeScript** - Full TypeScript support with type safety
14
- - 🧪 **Test-Friendly** - Easy to mock and test
8
+ ---
15
9
 
16
- ## 📦 Installation
10
+ ## Why this library?
17
11
 
18
- ```bash
19
- npm install @ackplus/nest-file-storage
20
- # or
21
- pnpm add @ackplus/nest-file-storage
22
- # or
23
- yarn add @ackplus/nest-file-storage
24
- ```
12
+ Wiring Multer to S3/Azure/local by hand means juggling storage engines, SDK clients, key generation, URL building, and validation in every project — and swapping providers later means rewriting it all. This library gives you **one stable API** over all of them.
25
13
 
26
- **For AWS S3 support:**
14
+ - **Provider-agnostic.** Write your upload code once. Switch Local → S3 → Azure (or your own backend) by changing config, not controllers.
15
+ - **Custom storage is first-class.** Implement a small `StorageDriver` interface and it works **everywhere** — interceptor and service alike. No fork, no adapter glue.
16
+ - **Per-request / multi-tenant storage.** Route each upload to the right bucket/folder based on the request (tenant, user plan, file type). Built-in caching means one client per tenant, not per request.
17
+ - **Declarative validation.** Size, MIME type, extension, and file-count limits as data — not hand-rolled checks scattered through your handlers. Rejections are typed `400`s.
18
+ - **One result shape.** Every upload — local or cloud — returns the same `UploadedFile` (`key`, `url`, `size`, …), so the rest of your app never branches on provider.
19
+ - **Lean dependencies.** The AWS and Azure SDKs are optional peers, loaded lazily only if you use them. Install nothing extra for local storage.
20
+ - **NestJS-native.** A dynamic module, an injectable service, and a route interceptor — the patterns you already use.
27
21
 
28
- ```bash
29
- npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
30
- ```
22
+ > **Upgrading from v1?** See **[MIGRATION.md](./MIGRATION.md)**. Your old config still boots (with a deprecation warning) while you migrate.
31
23
 
32
- **For Azure Blob Storage support:**
24
+ ## Contents
33
25
 
34
- ```bash
35
- npm install @azure/storage-blob
36
- ```
37
-
38
- ## 🚀 Quick Start
39
-
40
- ### Step 1: Configure Module
26
+ - [Install](#install) · [Quick start](#quick-start) · [Core concepts](#core-concepts)
27
+ - [Configure providers](#configure-providers) · [Provider comparison](#provider-comparison)
28
+ - [Custom storage drivers](#custom-storage-drivers) · [Multi-tenant storage](#multi-tenant-storage)
29
+ - [Uploading in controllers](#uploading-in-controllers) · [Validation & limits](#validation--limits)
30
+ - [Mapping results into the body](#mapping-results-into-the-request-body) · [Using the service](#using-the-service-programmatically)
31
+ - [The `UploadedFile` model (file state)](#the-uploadedfile-model-file-state) · [API reference](#api-reference)
41
32
 
42
- Choose your storage provider and configure the module:
33
+ ---
43
34
 
44
- #### Local Storage
35
+ ## Install
45
36
 
46
- ```typescript
47
- // app.module.ts
48
- import { Module } from '@nestjs/common';
49
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
50
-
51
- @Module({
52
- imports: [
53
- NestFileStorageModule.forRoot({
54
- storage: FileStorageEnum.LOCAL,
55
- localConfig: {
56
- rootPath: './uploads',
57
- baseUrl: 'http://localhost:3000/uploads',
58
- },
59
- }),
60
- ],
61
- })
62
- export class AppModule {}
37
+ ```bash
38
+ npm i @ackplus/nest-file-storage multer reflect-metadata
39
+ # AWS S3 (optional):
40
+ npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
41
+ # Azure Blob (optional):
42
+ npm i @azure/storage-blob
63
43
  ```
64
44
 
65
- #### AWS S3
45
+ Requires NestJS on **Express** (`@nestjs/platform-express`). Peer ranges: `@nestjs/common`/`@nestjs/core` `^10 || ^11`, `multer` `^1.4.5-lts.1 || ^2`, `reflect-metadata` `^0.2.2`.
66
46
 
67
- ```typescript
68
- // app.module.ts
69
- import { Module } from '@nestjs/common';
70
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
71
-
72
- @Module({
73
- imports: [
74
- NestFileStorageModule.forRoot({
75
- storage: FileStorageEnum.S3,
76
- s3Config: {
77
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
78
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
79
- region: process.env.AWS_REGION,
80
- bucket: process.env.AWS_BUCKET,
81
- },
82
- }),
83
- ],
84
- })
85
- export class AppModule {}
86
- ```
47
+ ## Quick start
87
48
 
88
- #### Azure Blob Storage
49
+ **1. Register a driver:**
89
50
 
90
- ```typescript
91
- // app.module.ts
51
+ ```ts
92
52
  import { Module } from '@nestjs/common';
93
- import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-storage';
53
+ import { NestFileStorageModule, localDriver } from '@ackplus/nest-file-storage';
94
54
 
95
55
  @Module({
96
56
  imports: [
97
57
  NestFileStorageModule.forRoot({
98
- storage: FileStorageEnum.AZURE,
99
- azureConfig: {
100
- account: process.env.AZURE_STORAGE_ACCOUNT,
101
- accountKey: process.env.AZURE_STORAGE_KEY,
102
- container: process.env.AZURE_CONTAINER,
58
+ default: 'local',
59
+ drivers: {
60
+ local: localDriver({
61
+ rootPath: './uploads',
62
+ baseUrl: 'http://localhost:3000/uploads',
63
+ }),
103
64
  },
104
65
  }),
105
66
  ],
@@ -107,557 +68,402 @@ import { NestFileStorageModule, FileStorageEnum } from '@ackplus/nest-file-stora
107
68
  export class AppModule {}
108
69
  ```
109
70
 
110
- ### Step 2: Upload Files in Controller
71
+ **2. Accept uploads in a controller:**
111
72
 
112
- ```typescript
113
- // upload.controller.ts
114
- import { Controller, Post, UseInterceptors } from '@nestjs/common';
73
+ ```ts
74
+ import { Body, Controller, Post, UseInterceptors } from '@nestjs/common';
115
75
  import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
116
76
 
117
- @Controller('upload')
118
- export class UploadController {
119
- // Single file upload
120
- @Post('single')
77
+ @Controller('files')
78
+ export class FilesController {
79
+ @Post('upload')
121
80
  @UseInterceptors(FileStorageInterceptor('file'))
122
- uploadSingle(@Body() body: any) {
123
- // File key is automatically added to body.file
124
- return {
125
- message: 'File uploaded successfully',
126
- fileKey: body.file,
127
- };
128
- }
129
-
130
- // Multiple files upload
131
- @Post('multiple')
132
- @UseInterceptors(
133
- FileStorageInterceptor({
134
- type: 'array',
135
- fieldName: 'files',
136
- maxCount: 10,
137
- })
138
- )
139
- uploadMultiple(@Body() body: any) {
140
- // File keys are automatically added to body.files as array
141
- return {
142
- message: 'Files uploaded successfully',
143
- fileKeys: body.files,
144
- };
145
- }
146
-
147
- // Multiple fields
148
- @Post('fields')
149
- @UseInterceptors(
150
- FileStorageInterceptor({
151
- type: 'fields',
152
- fields: [
153
- { name: 'avatar', maxCount: 1 },
154
- { name: 'photos', maxCount: 5 },
155
- ],
156
- })
157
- )
158
- uploadFields(@Body() body: any) {
159
- return {
160
- message: 'Files uploaded successfully',
161
- avatar: body.avatar,
162
- photos: body.photos,
163
- };
81
+ upload(@Body() body: { file: string }) {
82
+ return { key: body.file }; // body.file is the stored key
164
83
  }
165
84
  }
166
85
  ```
167
86
 
168
- ### Step 3: Use File Storage Service
87
+ The interceptor parses `multipart/form-data`, stores the file, and writes the storage key into `request.body.file` before your handler runs.
169
88
 
170
- ```typescript
171
- // file.service.ts
172
- import { Injectable } from '@nestjs/common';
173
- import { FileStorageService } from '@ackplus/nest-file-storage';
89
+ ## Core concepts
174
90
 
175
- @Injectable()
176
- export class FileService {
177
- // Get file
178
- async getFile(key: string): Promise<Buffer> {
179
- const storage = await FileStorageService.getStorage();
180
- return await storage.getFile(key);
181
- }
91
+ - **Driver** — a backend implementing the [`StorageDriver`](#the-storagedriver-interface) contract (put/get/delete/copy/url). Built-ins: `localDriver`, `s3Driver`, `azureDriver`. Bring your own with `defineDriver`.
92
+ - **Registry** the module builds a registry from your `drivers` map. Drivers are instantiated **once** and cached. The interceptor and the service both resolve from it, so everything behaves the same.
93
+ - **Default driver** — `default` names the driver used when nothing else is specified.
94
+ - **`UploadedFile`** the one result shape returned by every driver. See [file state](#the-uploadedfile-model-file-state).
182
95
 
183
- // Delete file
184
- async deleteFile(key: string): Promise<void> {
185
- const storage = await FileStorageService.getStorage();
186
- await storage.deleteFile(key);
187
- }
96
+ ## Configure providers
188
97
 
189
- // Copy file
190
- async copyFile(oldKey: string, newKey: string) {
191
- const storage = await FileStorageService.getStorage();
192
- return await storage.copyFile(oldKey, newKey);
193
- }
98
+ ### Local
194
99
 
195
- // Get public URL
196
- async getFileUrl(key: string): Promise<string> {
197
- const storage = await FileStorageService.getStorage();
198
- return storage.getUrl(key);
199
- }
200
-
201
- // Get signed URL (S3 only)
202
- async getSignedUrl(key: string): Promise<string> {
203
- const storage = await FileStorageService.getStorage();
204
- if ('getSignedUrl' in storage) {
205
- return await storage.getSignedUrl(key, { expiresIn: 3600 });
206
- }
207
- return storage.getUrl(key);
208
- }
209
- }
100
+ ```ts
101
+ localDriver({ rootPath: './uploads', baseUrl: 'http://localhost:3000/uploads' })
210
102
  ```
211
103
 
212
- ## 📚 Configuration Options
104
+ > `baseUrl` only builds the URL string — it does not serve files. Add static serving (e.g. `@nestjs/serve-static`) for browser access, or stream files through your own controller.
213
105
 
214
- ### Local Storage Options
106
+ ### AWS S3 (and S3-compatible: MinIO, R2, Spaces)
215
107
 
216
- ```typescript
217
- interface LocalStorageOptions {
218
- rootPath: string; // Directory to store files
219
- baseUrl: string; // Base URL for file access
220
- prefix?: string; // Optional prefix for file keys
221
- fileName?: (file: any, req: Request) => string; // Custom file naming
222
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
223
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
224
- }
108
+ ```ts
109
+ s3Driver({
110
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
111
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
112
+ region: process.env.AWS_REGION!,
113
+ bucket: process.env.AWS_BUCKET!,
114
+ endpoint: process.env.S3_ENDPOINT, // optional, for S3-compatible
115
+ cloudFrontUrl: process.env.CDN_URL, // optional, used by getUrl()
116
+ })
225
117
  ```
226
118
 
227
- ### S3 Storage Options
228
-
229
- ```typescript
230
- interface S3StorageOptions {
231
- accessKeyId: string; // AWS access key
232
- secretAccessKey: string; // AWS secret key
233
- region: string; // AWS region
234
- bucket: string; // S3 bucket name
235
- endpoint?: string; // Custom S3 endpoint (for S3-compatible services)
236
- cloudFrontUrl?: string; // CloudFront distribution URL
237
- prefix?: string; // Optional prefix for file keys
238
- fileName?: (file: any, req: Request) => string; // Custom file naming
239
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
240
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
241
- }
242
- ```
119
+ ### Azure Blob Storage
243
120
 
244
- ### Azure Storage Options
245
-
246
- ```typescript
247
- interface AzureStorageOptions {
248
- account: string; // Azure storage account name
249
- accountKey: string; // Azure storage account key
250
- container: string; // Container name
251
- prefix?: string; // Optional prefix for file keys
252
- fileName?: (file: any, req: Request) => string; // Custom file naming
253
- fileDist?: (file: any, req: Request) => string; // Custom directory structure
254
- transformUploadedFileObject?: (file: any) => any; // Transform uploaded file object
255
- }
121
+ ```ts
122
+ azureDriver({
123
+ account: process.env.AZURE_ACCOUNT!,
124
+ accountKey: process.env.AZURE_KEY!,
125
+ container: process.env.AZURE_CONTAINER!,
126
+ cdnUrl: process.env.AZURE_CDN_URL, // optional, used by getSignedUrl()
127
+ })
256
128
  ```
257
129
 
258
- ## 🎨 Advanced Usage
130
+ ### Async configuration
259
131
 
260
- ### Custom File Naming
132
+ Use `forRootAsync` when config comes from `ConfigService`, a database, etc.:
261
133
 
262
- ```typescript
263
- NestFileStorageModule.forRoot({
264
- storage: FileStorageEnum.LOCAL,
265
- localConfig: {
266
- rootPath: './uploads',
267
- baseUrl: 'http://localhost:3000/uploads',
268
- fileName: (file, req) => {
269
- // Custom file name with timestamp
270
- const timestamp = Date.now();
271
- const ext = file.originalname.split('.').pop();
272
- return `${timestamp}-${file.originalname}`;
134
+ ```ts
135
+ NestFileStorageModule.forRootAsync({
136
+ imports: [ConfigModule],
137
+ inject: [ConfigService],
138
+ useFactory: (config: ConfigService) => ({
139
+ default: 's3',
140
+ drivers: {
141
+ s3: s3Driver({
142
+ accessKeyId: config.getOrThrow('AWS_ACCESS_KEY_ID'),
143
+ secretAccessKey: config.getOrThrow('AWS_SECRET_ACCESS_KEY'),
144
+ region: config.getOrThrow('AWS_REGION'),
145
+ bucket: config.getOrThrow('AWS_BUCKET'),
146
+ }),
273
147
  },
274
- },
275
- })
148
+ }),
149
+ });
276
150
  ```
277
151
 
278
- ### Custom Directory Structure
152
+ ### Provider comparison
279
153
 
280
- ```typescript
281
- NestFileStorageModule.forRoot({
282
- storage: FileStorageEnum.LOCAL,
283
- localConfig: {
284
- rootPath: './uploads',
285
- baseUrl: 'http://localhost:3000/uploads',
286
- fileDist: (file, req) => {
287
- // Organize by year/month/day
288
- const date = new Date();
289
- return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
290
- },
291
- },
292
- })
293
- ```
154
+ | Capability | Local | S3 | Azure | Custom |
155
+ | --- | :---: | :---: | :---: | :---: |
156
+ | `putFile` / `getFile` / `deleteFile` / `copyFile` | ✅ | ✅ | ✅ | ✅ |
157
+ | `getUrl` | ✅ | ✅ | ✅ | ✅ |
158
+ | `getSignedUrl` | — | ✅ | ✅ (SAS) | optional |
159
+ | `path` (absolute FS path) | ✅ | — | — | optional |
160
+ | Public URLs need extra setup | static serving | bucket/CDN policy | container/CDN policy | your call |
161
+ | Extra dependency | none | `@aws-sdk/*` | `@azure/storage-blob` | your SDK |
294
162
 
295
- ### Transform Uploaded File Object
163
+ ## Custom storage drivers
296
164
 
297
- ```typescript
298
- NestFileStorageModule.forRoot({
299
- storage: FileStorageEnum.S3,
300
- s3Config: {
301
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
302
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
303
- region: process.env.AWS_REGION,
304
- bucket: process.env.AWS_BUCKET,
305
- transformUploadedFileObject: (file) => {
306
- // Return only specific fields
307
- return {
308
- key: file.key,
309
- url: file.url,
310
- size: file.size,
311
- mimetype: file.mimetype,
312
- };
313
- },
314
- },
315
- })
316
- ```
165
+ Implement the small [`StorageDriver`](#the-storagedriver-interface) interface and register it with `defineDriver`. It then works in the interceptor, the service, and tenant resolution — identically to the built-ins.
317
166
 
318
- ### Custom File Mapping in Interceptor
167
+ ```ts
168
+ import { defineDriver, StorageDriver, UploadedFile } from '@ackplus/nest-file-storage';
169
+ import { Storage } from '@google-cloud/storage';
319
170
 
320
- ```typescript
321
- @Post('upload')
322
- @UseInterceptors(
323
- FileStorageInterceptor('file', {
324
- mapToRequestBody: (file, fieldName, req) => {
325
- // Return full file object instead of just key
326
- return file;
327
- },
328
- })
329
- )
330
- uploadFile(@Body() body: any) {
331
- // body.file now contains the full file object
332
- return {
333
- message: 'File uploaded',
334
- file: body.file,
335
- };
171
+ class GcsDriver implements StorageDriver {
172
+ private storage = new Storage();
173
+ constructor(private opts: { bucket: string }) {}
174
+
175
+ async putFile(content: Buffer, key: string, meta?): Promise<UploadedFile> {
176
+ await this.storage.bucket(this.opts.bucket).file(key).save(content, {
177
+ contentType: meta?.contentType,
178
+ });
179
+ return {
180
+ key, url: this.getUrl(key), originalName: key.split('/').pop()!,
181
+ fileName: key.split('/').pop()!, size: content.length, fullPath: key,
182
+ };
183
+ }
184
+ async getFile(key: string) { const [buf] = await this.storage.bucket(this.opts.bucket).file(key).download(); return buf; }
185
+ async deleteFile(key: string) { await this.storage.bucket(this.opts.bucket).file(key).delete(); }
186
+ async copyFile(src: string, dest: string) { /* copy + return UploadedFile */ }
187
+ getUrl(key: string) { return `https://storage.googleapis.com/${this.opts.bucket}/${key}`; }
336
188
  }
189
+
190
+ NestFileStorageModule.forRoot({
191
+ default: 'gcs',
192
+ drivers: { gcs: defineDriver(GcsDriver, { bucket: 'my-bucket' }) },
193
+ });
337
194
  ```
338
195
 
339
- ### Async Configuration
196
+ For async setup, register a plain factory instead: `drivers: { gcs: async () => new GcsDriver(await load()) }`.
197
+
198
+ ## Multi-tenant storage
199
+
200
+ Route each upload to the right storage based on the request — a different **folder per tenant** in one bucket, a **dedicated bucket per tenant**, or a mix. The library does no authentication: your guard/middleware identifies the tenant; these hooks read it.
201
+
202
+ ```ts
203
+ import { NestFileStorageModule, localDriver, s3Driver, tenantFrom } from '@ackplus/nest-file-storage';
340
204
 
341
- ```typescript
342
- // app.module.ts
343
205
  NestFileStorageModule.forRootAsync({
344
- imports: [ConfigModule],
345
- useFactory: async (configService: ConfigService) => ({
346
- storage: FileStorageEnum.S3,
347
- s3Config: {
348
- accessKeyId: configService.get('AWS_ACCESS_KEY_ID'),
349
- secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY'),
350
- region: configService.get('AWS_REGION'),
351
- bucket: configService.get('AWS_BUCKET'),
206
+ inject: [TenantStorageService],
207
+ useFactory: (tenants: TenantStorageService) => ({
208
+ default: 'local',
209
+ drivers: {
210
+ local: localDriver({ rootPath: './uploads', baseUrl: 'http://localhost:3000/uploads' }),
211
+ },
212
+ tenant: {
213
+ // Identify the tenant — try several strategies in order.
214
+ resolve: tenantFrom.first(
215
+ tenantFrom.jwt('tenantId'), // req.user.tenantId (after your auth guard)
216
+ tenantFrom.subdomain(), // acme.app.com -> 'acme'
217
+ tenantFrom.header('x-tenant-id'),
218
+ ),
219
+ // Resolve a tenant -> storage. Cached by tenant id (this runs once per tenant).
220
+ driver: async (tenantId) => {
221
+ const cfg = await tenants.find(tenantId); // your DB lookup
222
+ if (cfg?.dedicated) {
223
+ return { factory: s3Driver({ bucket: cfg.bucket, region: cfg.region,
224
+ accessKeyId: cfg.key, secretAccessKey: cfg.secret }) }; // dedicated bucket
225
+ }
226
+ return { use: 'local', prefix: `tenants/${tenantId}` }; // shared + folder
227
+ },
228
+ cache: { ttlMs: 10 * 60_000, max: 500 },
229
+ fallback: 'default', // no tenant on the request -> use the default driver ('error' to 400)
352
230
  },
353
231
  }),
354
- inject: [ConfigService],
355
- })
232
+ });
356
233
  ```
357
234
 
358
- ### Dynamic Storage Type
359
-
360
- ```typescript
361
- // Override storage type per route
362
- @Post('upload-to-s3')
363
- @UseInterceptors(
364
- FileStorageInterceptor('file', {
365
- storageType: FileStorageEnum.S3,
366
- })
367
- )
368
- uploadToS3(@Body() body: any) {
369
- return { fileKey: body.file };
370
- }
235
+ Controllers need **no tenant-specific code** — a plain `FileStorageInterceptor('file')` is routed automatically:
236
+
237
+ ```ts
238
+ @Post('upload')
239
+ @UseInterceptors(FileStorageInterceptor('file'))
240
+ upload(@Body() body: { file: string }) { return { key: body.file }; }
371
241
  ```
372
242
 
373
- ## 🔥 Complete Examples
243
+ Outside a request (jobs, URL generation), resolve a tenant's driver programmatically — it uses the same cache:
374
244
 
375
- ### Image Upload with Validation
245
+ ```ts
246
+ const { driver, prefix } = await this.fileStorage.getTenantDriver('acme');
247
+ const url = await driver.getUrl(key);
248
+ ```
376
249
 
377
- ```typescript
378
- import { Controller, Post, UseInterceptors, BadRequestException } from '@nestjs/common';
379
- import { FileStorageInterceptor } from '@ackplus/nest-file-storage';
250
+ When a tenant changes its storage settings, drop the cached driver: `this.fileStorage.getRegistry().invalidateTenant('acme')`.
380
251
 
381
- @Controller('images')
382
- export class ImageController {
383
- @Post('upload')
384
- @UseInterceptors(
385
- FileStorageInterceptor('image', {
386
- fileName: (file, req) => {
387
- // Validate image type
388
- const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
389
- if (!allowedTypes.includes(file.mimetype)) {
390
- throw new BadRequestException('Only image files are allowed');
391
- }
392
-
393
- // Generate unique filename
394
- const timestamp = Date.now();
395
- const ext = file.originalname.split('.').pop();
396
- return `image-${timestamp}.${ext}`;
397
- },
398
- fileDist: (file, req) => {
399
- // Organize by year/month
400
- const date = new Date();
401
- return `images/${date.getFullYear()}/${date.getMonth() + 1}`;
402
- },
403
- })
404
- )
405
- async uploadImage(@Body() body: any) {
406
- return {
407
- message: 'Image uploaded successfully',
408
- imageKey: body.image,
409
- };
410
- }
411
- }
252
+ **`tenant.driver(tenantId)` returns one of:**
253
+
254
+ | Return | Meaning |
255
+ | --- | --- |
256
+ | `'local'` | a registered driver, no prefix |
257
+ | `{ use: 'local', prefix: 'tenants/acme' }` | a shared driver + per-tenant key prefix (folder isolation) |
258
+ | `{ factory: s3Driver({...}), prefix? }` | a dedicated driver built for this tenant (bucket isolation) |
259
+
260
+ ## Uploading in controllers
261
+
262
+ `FileStorageInterceptor(field, options?)` accepts `multipart/form-data`, stores the file(s), and writes the result into `request.body`.
263
+
264
+ ```ts
265
+ // Single file (field name 'file')
266
+ @UseInterceptors(FileStorageInterceptor('file'))
267
+ upload(@Body() body: { file: string }) {}
268
+
269
+ // Multiple files in one field
270
+ @UseInterceptors(FileStorageInterceptor({ type: 'array', fieldName: 'photos', maxCount: 5 }))
271
+ uploadMany(@Body() body: { photos: string[] }) {}
272
+
273
+ // Multiple named fields
274
+ @UseInterceptors(FileStorageInterceptor({
275
+ type: 'fields',
276
+ fields: [{ name: 'avatar', maxCount: 1 }, { name: 'docs', maxCount: 10 }],
277
+ }))
278
+ uploadFields(@Body() body: { avatar: string[]; docs: string[] }) {}
412
279
  ```
413
280
 
414
- ### User Avatar Upload
281
+ | Mode | Accepted field(s) | Default `body` value |
282
+ | --- | --- | --- |
283
+ | `'file'` (string) | `file` | `body.file: string` |
284
+ | `{ type: 'array' }` | repeated `photos` or `photos[0]`, `photos[1]`, … | `body.photos: string[]` |
285
+ | `{ type: 'fields' }` | each named field | `body[field]: string[]` |
415
286
 
416
- ```typescript
417
- import { Controller, Post, UseInterceptors, Body } from '@nestjs/common';
418
- import { FileStorageInterceptor, FileStorageService } from '@ackplus/nest-file-storage';
287
+ ### Control the stored key
419
288
 
420
- @Controller('users')
421
- export class UserController {
422
- constructor(private readonly userService: UserService) {}
289
+ ```ts
290
+ FileStorageInterceptor('avatar', {
291
+ fileDist: (_file, req) => `users/${req.user.id}/avatars`, // folder (relative)
292
+ fileName: (file) => `${Date.now()}${extname(file.originalname)}`, // last segment
293
+ prefix: 'public', // optional static prefix
294
+ });
295
+ // -> public/users/42/avatars/1713876155123.png
296
+ ```
423
297
 
424
- @Post('avatar')
425
- @UseInterceptors(
426
- FileStorageInterceptor('avatar', {
427
- fileName: (file, req) => {
428
- const userId = req.user.id; // Assuming user from auth guard
429
- const ext = file.originalname.split('.').pop();
430
- return `avatar-${userId}.${ext}`;
431
- },
432
- fileDist: () => 'avatars',
433
- })
434
- )
435
- async uploadAvatar(@Body() body: any, @Request() req) {
436
- // Delete old avatar if exists
437
- const user = await this.userService.findById(req.user.id);
438
- if (user.avatarKey) {
439
- const storage = await FileStorageService.getStorage();
440
- await storage.deleteFile(user.avatarKey);
441
- }
442
-
443
- // Update user with new avatar
444
- await this.userService.updateAvatar(req.user.id, body.avatar);
298
+ The final key is `joinKey(prefix, fileDist, fileName)`. Defaults: `fileDist` = `YYYY/MM/DD`, `fileName` = `uuid-originalname`.
445
299
 
446
- return {
447
- message: 'Avatar updated successfully',
448
- avatarKey: body.avatar,
449
- };
450
- }
451
- }
300
+ ### Override the driver for one route
301
+
302
+ ```ts
303
+ FileStorageInterceptor('file', { driver: 's3' }); // by name
304
+ FileStorageInterceptor('file', { driver: (req) => req.user.plan }); // dynamically
305
+ FileStorageInterceptor('file', { tenant: false }); // opt out of tenant routing
452
306
  ```
453
307
 
454
- ### Document Management
308
+ ## Validation & limits
455
309
 
456
- ```typescript
457
- import { Controller, Get, Post, Delete, Param, UseInterceptors, Body } from '@nestjs/common';
458
- import { FileStorageInterceptor, FileStorageService } from '@ackplus/nest-file-storage';
310
+ Declare limits as data — at the module level (applies to every route) and/or per route (merged over the module default). Rejections throw typed `400`s.
459
311
 
460
- @Controller('documents')
461
- export class DocumentController {
462
- @Post('upload')
463
- @UseInterceptors(
464
- FileStorageInterceptor({
465
- type: 'array',
466
- fieldName: 'documents',
467
- maxCount: 10,
468
- }, {
469
- fileDist: () => 'documents',
470
- mapToRequestBody: (files, fieldName) => {
471
- // Return detailed file info
472
- return files;
473
- },
474
- })
475
- )
476
- async uploadDocuments(@Body() body: any) {
477
- return {
478
- message: `${body.documents.length} documents uploaded`,
479
- documents: body.documents,
480
- };
481
- }
312
+ ```ts
313
+ // module-wide default
314
+ validation: { maxSize: 10 * 1024 * 1024 }
482
315
 
483
- @Get(':key/download')
484
- async downloadDocument(@Param('key') key: string, @Res() res) {
485
- const storage = await FileStorageService.getStorage();
486
- const file = await storage.getFile(key);
487
-
488
- res.setHeader('Content-Type', 'application/octet-stream');
489
- res.setHeader('Content-Disposition', `attachment; filename="${key}"`);
490
- res.send(file);
491
- }
316
+ // per route
317
+ FileStorageInterceptor('image', {
318
+ validation: {
319
+ maxSize: 5 * 1024 * 1024, // bytes
320
+ allowedMimeTypes: ['image/jpeg', 'image/png', 'image/*'], // wildcards supported
321
+ allowedExtensions: ['.jpg', '.png'], // case-insensitive
322
+ maxFiles: 10, // total files per request
323
+ fileFilter: (req, file, cb) => cb(null, true), // escape hatch (runs after the above)
324
+ },
325
+ });
326
+ ```
492
327
 
493
- @Delete(':key')
494
- async deleteDocument(@Param('key') key: string) {
495
- const storage = await FileStorageService.getStorage();
496
- await storage.deleteFile(key);
497
-
498
- return { message: 'Document deleted successfully' };
499
- }
328
+ | Rule | Becomes | Throws on violation |
329
+ | --- | --- | --- |
330
+ | `maxSize` | Multer `limits.fileSize` | `FileTooLargeException` |
331
+ | `maxFiles` | Multer `limits.files` | `TooManyFilesException` |
332
+ | `allowedMimeTypes` / `allowedExtensions` | generated `fileFilter` | `InvalidFileTypeException` |
500
333
 
501
- @Get(':key/url')
502
- async getDocumentUrl(@Param('key') key: string) {
503
- const storage = await FileStorageService.getStorage();
504
- const url = storage.getUrl(key);
505
-
506
- return { url };
507
- }
508
- }
334
+ All three extend `BadRequestException`, so they surface as standard `400` responses. For cross-field rules (e.g. "at least 2 images"), use `afterUpload`:
335
+
336
+ ```ts
337
+ FileStorageInterceptor({ type: 'fields', fields: [{ name: 'images', maxCount: 10 }] }, {
338
+ afterUpload: (req) => {
339
+ const files = req.files as Record<string, Express.Multer.File[]>;
340
+ if ((files?.images?.length ?? 0) < 2) throw new BadRequestException('At least 2 images required.');
341
+ },
342
+ });
509
343
  ```
510
344
 
511
- ## 📚 API Reference
345
+ ## Mapping results into the request body
512
346
 
513
- ### FileStorageService
347
+ By default the interceptor writes the storage **key** into `request.body[field]`. Customize it with `mapToRequestBody`:
514
348
 
515
- ```typescript
516
- class FileStorageService {
517
- // Get storage instance
518
- static async getStorage(storageType?: FileStorageEnum): Promise<Storage>
519
-
520
- // Get module options
521
- static getOptions(): FileStorageModuleOptions
522
-
523
- // Set module options
524
- static setOptions(options: FileStorageModuleOptions): void
525
- }
349
+ ```ts
350
+ FileStorageInterceptor('document', {
351
+ mapToRequestBody: (file) => ({ key: file.key, url: file.url, size: file.size }),
352
+ });
353
+ // body.document === { key, url, size }
526
354
  ```
527
355
 
528
- ### Storage Interface
529
-
530
- ```typescript
531
- interface Storage {
532
- // Get file content as Buffer
533
- getFile(key: string): Promise<Buffer> | Buffer
534
-
535
- // Delete file
536
- deleteFile(key: string): Promise<void> | void
537
-
538
- // Upload file
539
- putFile(fileContent: Buffer, key: string): Promise<UploadedFile> | UploadedFile
540
-
541
- // Copy file
542
- copyFile(oldKey: string, newKey: string): Promise<UploadedFile>
543
-
544
- // Get file URL
545
- getUrl(key: string): Promise<string> | string
546
-
547
- // Get signed URL (S3 only)
548
- getSignedUrl?(key: string, options: any): Promise<string> | string
549
-
550
- // Get file path (Local only)
551
- path?(filePath: string): Promise<string> | string
356
+ Set `overwriteBodyField: false` to keep an existing body value (e.g. a JSON field with the same name on a PATCH).
357
+
358
+ ## Using the service programmatically
359
+
360
+ `FileStorageService` is injectable (the module is global). Use it in services, jobs, or response mappers.
361
+
362
+ ```ts
363
+ import { Injectable } from '@nestjs/common';
364
+ import { FileStorageService } from '@ackplus/nest-file-storage';
365
+
366
+ @Injectable()
367
+ export class DocsService {
368
+ constructor(private readonly fileStorage: FileStorageService) {}
369
+
370
+ upload(buf: Buffer, key: string) { return this.fileStorage.putFile(buf, key); }
371
+ read(key: string) { return this.fileStorage.getFile(key); }
372
+ remove(key: string) { return this.fileStorage.deleteFile(key); }
373
+ url(key: string) { return this.fileStorage.getUrl(key); }
374
+ signedUrl(key: string) { return this.fileStorage.getSignedUrl(key, { expiresIn: 3600 }); }
375
+
376
+ // a specific driver, or a tenant's driver:
377
+ async s3() { return this.fileStorage.getDriver('s3'); }
378
+ async forTenant(id: string) { return this.fileStorage.getTenantDriver(id); }
552
379
  }
553
380
  ```
554
381
 
555
- ### FileStorageInterceptor
556
-
557
- ```typescript
558
- // Single file upload
559
- FileStorageInterceptor(
560
- fieldName: string,
561
- options?: FileStorageInterceptorOptions
562
- )
563
-
564
- // Multiple files or fields
565
- FileStorageInterceptor(
566
- config: {
567
- type: 'single' | 'array' | 'fields';
568
- fieldName?: string;
569
- maxCount?: number;
570
- fields?: { name: string; maxCount?: number }[];
571
- },
572
- options?: FileStorageInterceptorOptions
573
- )
382
+ A common pattern: store only the `key` in your database and build the URL when serializing a response.
383
+
384
+ ```ts
385
+ async toResponse(user: { avatarKey?: string }) {
386
+ return { ...user, avatarUrl: user.avatarKey ? await this.fileStorage.getUrl(user.avatarKey) : null };
387
+ }
574
388
  ```
575
389
 
576
- ### UploadedFile Interface
390
+ ## The `UploadedFile` model (file state)
391
+
392
+ Every driver returns the same shape from `putFile`/`copyFile`, and the interceptor exposes it to `mapToRequestBody`. Persist the **`key`** (stable); derive the **`url`** when you need it.
577
393
 
578
- ```typescript
394
+ ```ts
579
395
  interface UploadedFile {
580
- fieldName?: string; // Form field name
581
- fileName: string; // Generated file name
582
- originalName: string; // Original file name
583
- size: number; // File size in bytes
584
- mimetype?: string; // MIME type
585
- buffer?: Buffer; // File buffer (optional)
586
- key: string; // Storage key/path
587
- url: string; // Public URL
588
- fullPath: string; // Full storage path
589
- encoding?: string; // File encoding
396
+ key: string; // storage key/path — persist THIS
397
+ url: string; // public URL (local needs static serving)
398
+ originalName: string; // original client filename
399
+ fileName: string; // final filename segment of the key
400
+ size: number; // bytes
401
+ mimetype?: string; // when known
402
+ fieldName?: string; // upload field it came from
403
+ fullPath: string; // provider-native path (local absolute; cloud key)
404
+ encoding?: string;
405
+ buffer?: Buffer; // when the provider returns bytes
590
406
  }
591
407
  ```
592
408
 
593
- ## 🧪 Testing
594
-
595
- ```typescript
596
- import { Test, TestingModule } from '@nestjs/testing';
597
- import { NestFileStorageModule, FileStorageService, FileStorageEnum } from '@ackplus/nest-file-storage';
598
-
599
- describe('FileService', () => {
600
- let service: FileService;
601
- let storage: Storage;
602
-
603
- beforeEach(async () => {
604
- const module: TestingModule = await Test.createTestingModule({
605
- imports: [
606
- NestFileStorageModule.forRoot({
607
- storage: FileStorageEnum.LOCAL,
608
- localConfig: {
609
- rootPath: './test-uploads',
610
- baseUrl: 'http://localhost:3000/test-uploads',
611
- },
612
- }),
613
- ],
614
- providers: [FileService],
615
- }).compile();
616
-
617
- service = module.get<FileService>(FileService);
618
- storage = await FileStorageService.getStorage();
619
- });
620
-
621
- it('should upload file', async () => {
622
- const buffer = Buffer.from('test content');
623
- const result = await storage.putFile(buffer, 'test/file.txt');
624
-
625
- expect(result.key).toBe('test/file.txt');
626
- expect(result.size).toBeGreaterThan(0);
627
- });
628
-
629
- it('should delete file', async () => {
630
- const buffer = Buffer.from('test content');
631
- await storage.putFile(buffer, 'test/file.txt');
632
-
633
- await storage.deleteFile('test/file.txt');
634
-
635
- await expect(storage.getFile('test/file.txt')).rejects.toThrow();
636
- });
637
- });
638
- ```
409
+ | Field | Use it for |
410
+ | --- | --- |
411
+ | `key` | the database value; the input to `getFile`/`getUrl`/`deleteFile`/`copyFile` |
412
+ | `url` | rendering/links (regenerate from `key` via `getUrl` rather than storing) |
413
+ | `fullPath` | local file operations; provider-native identifier |
414
+ | `size` / `mimetype` / `originalName` | metadata you may want to persist alongside the key |
415
+
416
+ ## API reference
417
+
418
+ ### `NestFileStorageModule`
639
419
 
640
- ## 🤝 Contributing
420
+ - `forRoot(options)` / `forRootAsync(asyncOptions)` → a global `DynamicModule`.
421
+ - Options: `{ default: string; drivers: Record<string, DriverFactory>; validation?: UploadValidation; tenant?: TenantOptions }`.
641
422
 
642
- Contributions are welcome! Please feel free to submit a Pull Request.
423
+ ### Driver factories
643
424
 
644
- ## 📄 License
425
+ - `localDriver(options)` · `s3Driver(options)` · `azureDriver(options)` · `defineDriver(DriverClass, options?)` → `DriverFactory`.
645
426
 
646
- This project is licensed under the MIT License.
427
+ ### `FileStorageService` (injectable)
647
428
 
648
- ## 🙏 Acknowledgments
429
+ - `getDriver(name?)` · `getTenantDriver(tenantId)` · `getRegistry()`
430
+ - `putFile` · `getFile` · `deleteFile` · `copyFile` · `getUrl` · `getSignedUrl` (delegate to the default driver)
431
+ - `static getStorage(name?)` — *deprecated* facade for non-DI call sites.
649
432
 
650
- - Built with [NestJS](https://nestjs.com/)
651
- - Uses [Multer](https://github.com/expressjs/multer) for file handling
652
- - AWS S3 support via [@aws-sdk/client-s3](https://www.npmjs.com/package/@aws-sdk/client-s3)
653
- - Azure support via [@azure/storage-blob](https://www.npmjs.com/package/@azure/storage-blob)
433
+ ### `FileStorageInterceptor(field, options?)`
654
434
 
655
- ## 📮 Support
435
+ `field`: a string (single file) or `{ type, fieldName?, maxCount?, fields? }`.
436
+ `options`: `{ driver?, fileName?, fileDist?, prefix?, validation?, mapToRequestBody?, overwriteBodyField?, afterUpload?, tenant? }`.
656
437
 
657
- If you have any questions or need help:
658
- - Open an issue on [GitHub](https://github.com/ack-solutions/nest-file-storage/issues)
659
- - Check the [examples](./examples/) directory
438
+ ### The `StorageDriver` interface
439
+
440
+ ```ts
441
+ interface StorageDriver {
442
+ putFile(content: Buffer, key: string, meta?: PutFileMeta): Promise<UploadedFile>;
443
+ getFile(key: string): Promise<Buffer>;
444
+ deleteFile(key: string): Promise<void>;
445
+ copyFile(sourceKey: string, destKey: string): Promise<UploadedFile>;
446
+ getUrl(key: string): string | Promise<string>;
447
+ getSignedUrl?(key: string, options?: SignedUrlOptions): Promise<string>;
448
+ path?(key: string): string | Promise<string>;
449
+ readonly keyDefaults?: { fileName?; fileDist?; prefix? };
450
+ }
451
+ ```
452
+
453
+ ### Tenant resolvers — `tenantFrom`
454
+
455
+ `jwt(path?)` · `header(name)` · `subdomain({ rootDomain?, ignore? })` · `param(name)` · `query(name)` · `first(...resolvers)`.
456
+
457
+ ### Exceptions
458
+
459
+ `FileTooLargeException` · `InvalidFileTypeException` · `TooManyFilesException` (all extend `BadRequestException`).
660
460
 
661
461
  ---
662
462
 
663
- Made with ❤️ for the NestJS community
463
+ ## More
464
+
465
+ - **[Migration from v1](./MIGRATION.md)** · **[Changelog](./CHANGELOG.md)** · **[Examples](./examples/)**
466
+
467
+ ## License
468
+
469
+ MIT