@opensaas/stack-storage 0.23.0 → 0.25.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +32 -0
- package/CLAUDE.md +120 -18
- package/dist/runtime/index.d.ts +12 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +21 -7
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/registry.d.ts +57 -0
- package/dist/runtime/registry.d.ts.map +1 -0
- package/dist/runtime/registry.js +81 -0
- package/dist/runtime/registry.js.map +1 -0
- package/package.json +2 -2
- package/src/runtime/index.ts +31 -8
- package/src/runtime/registry.ts +106 -0
- package/tests/provider-registry.test.ts +201 -0
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @opensaas/stack-storage
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
## 0.24.0
|
|
6
|
+
|
|
7
|
+
### Minor Changes
|
|
8
|
+
|
|
9
|
+
- [#551](https://github.com/OpenSaasAU/stack/pull/551) [`fb979b8`](https://github.com/OpenSaasAU/stack/commit/fb979b8978f9bdefd2b4f81e87c1c198582200ae) Thanks [@borisno2](https://github.com/borisno2)! - Add a storage provider registration API so non-`local` and custom providers are constructable.
|
|
10
|
+
|
|
11
|
+
`createStorageProvider` now resolves a provider `type` through a registry instead of a hardcoded `switch`, which previously only built `'local'` and threw for everything else. `'local'` is registered as a built-in default, so existing behaviour is unchanged. The host opts into the optional provider packages (`@opensaas/stack-storage-s3`, `@opensaas/stack-storage-vercel`) or a custom provider by registering it — `@opensaas/stack-storage` does not depend on the provider packages, keeping the AWS/Vercel SDKs off every storage user. Reads are unaffected: assembling existing asset metadata only stamps the provider name and never constructs a provider.
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// lib/register-storage.ts (server-only, imported at app startup)
|
|
15
|
+
import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
16
|
+
import { S3StorageProvider, type S3StorageConfig } from '@opensaas/stack-storage-s3'
|
|
17
|
+
|
|
18
|
+
registerStorageProvider<S3StorageConfig>('s3', (config) => new S3StorageProvider(config))
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// opensaas.config.ts — reference the registered provider by type
|
|
23
|
+
import { s3Storage } from '@opensaas/stack-storage-s3'
|
|
24
|
+
|
|
25
|
+
export default config({
|
|
26
|
+
storage: {
|
|
27
|
+
avatars: s3Storage({ bucket: 'user-avatars', region: 'us-east-1' }),
|
|
28
|
+
},
|
|
29
|
+
// ...
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Custom providers register the same way: implement `StorageProvider`, give it a `type`, then call `registerStorageProvider(type, (config) => new MyProvider(config))`. An unregistered type throws a clear error pointing at `registerStorageProvider`.
|
|
34
|
+
|
|
3
35
|
## 0.23.0
|
|
4
36
|
|
|
5
37
|
## 0.22.0
|
package/CLAUDE.md
CHANGED
|
@@ -50,7 +50,10 @@ packages/storage-vercel/ # Separate Vercel Blob provider package
|
|
|
50
50
|
- `uploadImage(config, provider, data, options)` - Upload image with transformations
|
|
51
51
|
- `deleteFile(config, provider, filename)` - Delete file
|
|
52
52
|
- `deleteImage(config, metadata)` - Delete image and all transformations
|
|
53
|
-
- `createStorageProvider(config, providerName)` - Create provider instance
|
|
53
|
+
- `createStorageProvider(config, providerName)` - Create provider instance (resolves the provider `type` through the registry)
|
|
54
|
+
- `registerStorageProvider(type, factory)` - Register a provider `type` → constructor (host opts in to optional/custom providers)
|
|
55
|
+
- `getStorageProviderFactory(type)` / `hasStorageProvider(type)` - Inspect the registry
|
|
56
|
+
- `resetStorageProviderRegistry()` - Reset to built-in defaults (mainly for tests)
|
|
54
57
|
|
|
55
58
|
### Utils (`src/utils/`)
|
|
56
59
|
|
|
@@ -352,40 +355,139 @@ avatar: image({
|
|
|
352
355
|
})
|
|
353
356
|
```
|
|
354
357
|
|
|
355
|
-
###
|
|
358
|
+
### Storage Provider Registry
|
|
359
|
+
|
|
360
|
+
`createStorageProvider` resolves a provider `type` through a **registry** rather
|
|
361
|
+
than a hardcoded `switch`. `'local'` is registered as a built-in default, so it
|
|
362
|
+
works with no setup. Every other provider — the optional packages
|
|
363
|
+
(`@opensaas/stack-storage-s3`, `@opensaas/stack-storage-vercel`) and your own
|
|
364
|
+
custom providers — must be **registered by the host** before
|
|
365
|
+
`createStorageProvider` can build them.
|
|
366
|
+
|
|
367
|
+
`@opensaas/stack-storage` deliberately does **not** depend on `-s3`/`-vercel`:
|
|
368
|
+
wiring them into the factory directly would force the AWS/Vercel SDKs onto every
|
|
369
|
+
storage user. The host opts in by registering only the provider(s) it uses.
|
|
370
|
+
|
|
371
|
+
#### Registering an optional provider package
|
|
372
|
+
|
|
373
|
+
Register once at app startup (e.g. in a server-only module imported by your app
|
|
374
|
+
entry, the upload route, or `instrumentation.ts`):
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
// lib/register-storage.ts (server-only)
|
|
378
|
+
import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
379
|
+
import { S3StorageProvider, type S3StorageConfig } from '@opensaas/stack-storage-s3'
|
|
380
|
+
|
|
381
|
+
registerStorageProvider<S3StorageConfig>('s3', (config) => new S3StorageProvider(config))
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Then reference the provider by `type` in config (via the package's config
|
|
385
|
+
builder, which sets `type: 's3'`):
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { s3Storage } from '@opensaas/stack-storage-s3'
|
|
389
|
+
|
|
390
|
+
export default config({
|
|
391
|
+
storage: {
|
|
392
|
+
avatars: s3Storage({ bucket: 'user-avatars', region: 'us-east-1' }),
|
|
393
|
+
},
|
|
394
|
+
// ...
|
|
395
|
+
})
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
The Vercel Blob provider follows the same shape:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
402
|
+
import {
|
|
403
|
+
VercelBlobStorageProvider,
|
|
404
|
+
type VercelBlobStorageConfig,
|
|
405
|
+
} from '@opensaas/stack-storage-vercel'
|
|
406
|
+
|
|
407
|
+
registerStorageProvider<VercelBlobStorageConfig>(
|
|
408
|
+
'vercel-blob',
|
|
409
|
+
(config) => new VercelBlobStorageProvider(config),
|
|
410
|
+
)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
#### Registering a custom provider
|
|
414
|
+
|
|
415
|
+
Implement `StorageProvider`, give it a `type` discriminator, then register it:
|
|
356
416
|
|
|
357
417
|
```typescript
|
|
358
418
|
// lib/cloudflare-r2-storage.ts
|
|
359
|
-
import type {
|
|
419
|
+
import type {
|
|
420
|
+
StorageProvider,
|
|
421
|
+
UploadOptions,
|
|
422
|
+
UploadResult,
|
|
423
|
+
BaseStorageConfig,
|
|
424
|
+
} from '@opensaas/stack-storage'
|
|
425
|
+
|
|
426
|
+
export interface CloudflareR2Config extends BaseStorageConfig {
|
|
427
|
+
type: 'cloudflare-r2'
|
|
428
|
+
bucket: string
|
|
429
|
+
accountId: string
|
|
430
|
+
}
|
|
360
431
|
|
|
361
432
|
export class CloudflareR2StorageProvider implements StorageProvider {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
433
|
+
constructor(private config: CloudflareR2Config) {}
|
|
434
|
+
|
|
435
|
+
async upload(
|
|
436
|
+
file: Buffer | Uint8Array,
|
|
437
|
+
filename: string,
|
|
438
|
+
options?: UploadOptions,
|
|
439
|
+
): Promise<UploadResult> {
|
|
440
|
+
// Upload to Cloudflare R2 ...
|
|
441
|
+
return {
|
|
442
|
+
filename,
|
|
443
|
+
url: this.getUrl(filename),
|
|
444
|
+
size: file.length,
|
|
445
|
+
contentType: options?.contentType ?? 'application/octet-stream',
|
|
446
|
+
}
|
|
366
447
|
}
|
|
367
448
|
|
|
368
|
-
async download(filename: string) {
|
|
369
|
-
// Download from R2
|
|
370
|
-
|
|
449
|
+
async download(filename: string): Promise<Buffer> {
|
|
450
|
+
// Download from R2 ...
|
|
451
|
+
return Buffer.from('')
|
|
371
452
|
}
|
|
372
453
|
|
|
373
|
-
async delete(filename: string) {
|
|
374
|
-
// Delete from R2
|
|
375
|
-
// ...
|
|
454
|
+
async delete(filename: string): Promise<void> {
|
|
455
|
+
// Delete from R2 ...
|
|
376
456
|
}
|
|
377
457
|
|
|
378
|
-
getUrl(filename: string) {
|
|
379
|
-
return `https://r2.example.com/${filename}`
|
|
458
|
+
getUrl(filename: string): string {
|
|
459
|
+
return `https://r2.example.com/${this.config.bucket}/${filename}`
|
|
380
460
|
}
|
|
381
461
|
}
|
|
462
|
+
```
|
|
382
463
|
|
|
383
|
-
|
|
384
|
-
|
|
464
|
+
```typescript
|
|
465
|
+
// lib/register-storage.ts (server-only)
|
|
466
|
+
import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
467
|
+
import { CloudflareR2StorageProvider, type CloudflareR2Config } from './cloudflare-r2-storage'
|
|
468
|
+
|
|
469
|
+
registerStorageProvider<CloudflareR2Config>(
|
|
470
|
+
'cloudflare-r2',
|
|
471
|
+
(config) => new CloudflareR2StorageProvider(config),
|
|
472
|
+
)
|
|
473
|
+
```
|
|
385
474
|
|
|
386
|
-
|
|
475
|
+
```typescript
|
|
476
|
+
// opensaas.config.ts
|
|
477
|
+
export default config({
|
|
478
|
+
storage: {
|
|
479
|
+
media: { type: 'cloudflare-r2', bucket: 'media', accountId: process.env.CF_ACCOUNT_ID! },
|
|
480
|
+
},
|
|
481
|
+
// ...
|
|
482
|
+
})
|
|
387
483
|
```
|
|
388
484
|
|
|
485
|
+
If a field references a provider whose `type` has not been registered,
|
|
486
|
+
`createStorageProvider` throws a clear error
|
|
487
|
+
(`Unknown storage provider type: <type>. Register it with registerStorageProvider(...)`).
|
|
488
|
+
Reads are unaffected — assembling existing asset metadata only stamps the
|
|
489
|
+
provider **name** and never constructs a provider.
|
|
490
|
+
|
|
389
491
|
## Type Safety
|
|
390
492
|
|
|
391
493
|
All types are strongly typed:
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -2,7 +2,17 @@ import type { OpenSaasConfig } from '@opensaas/stack-core';
|
|
|
2
2
|
import type { StorageProvider, FileMetadata, ImageMetadata, ImageTransformationConfig } from '../config/types.js';
|
|
3
3
|
import { type FileValidationOptions } from '../utils/upload.js';
|
|
4
4
|
/**
|
|
5
|
-
* Creates a storage provider instance from config
|
|
5
|
+
* Creates a storage provider instance from config.
|
|
6
|
+
*
|
|
7
|
+
* The provider `type` is resolved through the provider registry (see
|
|
8
|
+
* {@link registerStorageProvider}) rather than a closed `switch`. `'local'` is
|
|
9
|
+
* registered as a built-in default, so it works with no registration step.
|
|
10
|
+
* Optional providers (`@opensaas/stack-storage-s3`,
|
|
11
|
+
* `@opensaas/stack-storage-vercel`) and custom providers must be registered by
|
|
12
|
+
* the host before they can be constructed.
|
|
13
|
+
*
|
|
14
|
+
* @throws If the named provider is not present in `config.storage`.
|
|
15
|
+
* @throws If no provider factory has been registered for the config's `type`.
|
|
6
16
|
*/
|
|
7
17
|
export declare function createStorageProvider(config: OpenSaasConfig, providerName: string): StorageProvider;
|
|
8
18
|
/**
|
|
@@ -72,4 +82,5 @@ export declare function deleteFile(config: OpenSaasConfig, storageProviderName:
|
|
|
72
82
|
*/
|
|
73
83
|
export declare function deleteImage(config: OpenSaasConfig, metadata: ImageMetadata): Promise<void>;
|
|
74
84
|
export { parseFileFromFormData } from '../utils/upload.js';
|
|
85
|
+
export { registerStorageProvider, getStorageProviderFactory, hasStorageProvider, resetStorageProviderRegistry, type StorageProviderFactory, } from './registry.js';
|
|
75
86
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,KAAK,EACV,eAAe,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,KAAK,EACV,eAAe,EACf,YAAY,EACZ,aAAa,EACb,yBAAyB,EAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAA6B,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAI1F;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,cAAc,EACtB,YAAY,EAAE,MAAM,GACnB,eAAe,CAiBjB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,yBAAyB;IACzB,UAAU,CAAC,EAAE,qBAAqB,CAAA;IAClC,sBAAsB;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAClC;AAED;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,iBAAiB;IAC3D,qCAAqC;IACrC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAA;CAC5D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,cAAc,EACtB,mBAAmB,EAAE,MAAM,EAC3B,IAAI,EAAE;IACJ,IAAI,EAAE,IAAI,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf,EACD,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA0CvB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,EACtB,mBAAmB,EAAE,MAAM,EAC3B,IAAI,EAAE;IACJ,IAAI,EAAE,IAAI,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf,EACD,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,aAAa,CAAC,CA8DxB;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,cAAc,EACtB,mBAAmB,EAAE,MAAM,EAC3B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAGf;AAED;;GAEG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBhG;AAED,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAI1D,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,kBAAkB,EAClB,4BAA4B,EAC5B,KAAK,sBAAsB,GAC5B,MAAM,eAAe,CAAA"}
|
package/dist/runtime/index.js
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
import { LocalStorageProvider } from '../providers/local.js';
|
|
2
1
|
import { validateFile, getMimeType } from '../utils/upload.js';
|
|
3
2
|
import { getImageDimensions, processImageTransformations } from '../utils/image.js';
|
|
3
|
+
import { getStorageProviderFactory } from './registry.js';
|
|
4
4
|
/**
|
|
5
|
-
* Creates a storage provider instance from config
|
|
5
|
+
* Creates a storage provider instance from config.
|
|
6
|
+
*
|
|
7
|
+
* The provider `type` is resolved through the provider registry (see
|
|
8
|
+
* {@link registerStorageProvider}) rather than a closed `switch`. `'local'` is
|
|
9
|
+
* registered as a built-in default, so it works with no registration step.
|
|
10
|
+
* Optional providers (`@opensaas/stack-storage-s3`,
|
|
11
|
+
* `@opensaas/stack-storage-vercel`) and custom providers must be registered by
|
|
12
|
+
* the host before they can be constructed.
|
|
13
|
+
*
|
|
14
|
+
* @throws If the named provider is not present in `config.storage`.
|
|
15
|
+
* @throws If no provider factory has been registered for the config's `type`.
|
|
6
16
|
*/
|
|
7
17
|
export function createStorageProvider(config, providerName) {
|
|
8
18
|
if (!config.storage || !config.storage[providerName]) {
|
|
9
19
|
throw new Error(`Storage provider '${providerName}' not found in config`);
|
|
10
20
|
}
|
|
11
21
|
const providerConfig = config.storage[providerName];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const factory = getStorageProviderFactory(providerConfig.type);
|
|
23
|
+
if (!factory) {
|
|
24
|
+
throw new Error(`Unknown storage provider type: ${providerConfig.type}. ` +
|
|
25
|
+
`Register it with registerStorageProvider('${providerConfig.type}', ...) from ` +
|
|
26
|
+
`'@opensaas/stack-storage/runtime' before use.`);
|
|
17
27
|
}
|
|
28
|
+
return factory(providerConfig);
|
|
18
29
|
}
|
|
19
30
|
/**
|
|
20
31
|
* Uploads a file to the specified storage provider
|
|
@@ -154,4 +165,7 @@ export async function deleteImage(config, metadata) {
|
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
export { parseFileFromFormData } from '../utils/upload.js';
|
|
168
|
+
// Provider registration API: hosts register optional/custom providers so
|
|
169
|
+
// createStorageProvider can construct them (see registry.ts).
|
|
170
|
+
export { registerStorageProvider, getStorageProviderFactory, hasStorageProvider, resetStorageProviderRegistry, } from './registry.js';
|
|
157
171
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { StorageProvider, BaseStorageConfig } from '../config/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A factory that constructs a {@link StorageProvider} from its provider config.
|
|
4
|
+
*
|
|
5
|
+
* The factory is generic over the concrete config shape so a provider package
|
|
6
|
+
* can register with full type safety (e.g. `S3StorageConfig`), while the
|
|
7
|
+
* registry stores factories against the {@link BaseStorageConfig} contract that
|
|
8
|
+
* every provider config satisfies.
|
|
9
|
+
*
|
|
10
|
+
* @typeParam TConfig - The provider-specific config type (must extend
|
|
11
|
+
* {@link BaseStorageConfig}).
|
|
12
|
+
*/
|
|
13
|
+
export type StorageProviderFactory<TConfig extends BaseStorageConfig = BaseStorageConfig> = (config: TConfig) => StorageProvider;
|
|
14
|
+
/**
|
|
15
|
+
* Registers a storage provider factory for a given provider `type`.
|
|
16
|
+
*
|
|
17
|
+
* The host application calls this to opt in to a provider it uses — either one
|
|
18
|
+
* of the optional provider packages (`@opensaas/stack-storage-s3`,
|
|
19
|
+
* `@opensaas/stack-storage-vercel`) or a custom provider. Once registered,
|
|
20
|
+
* {@link createStorageProvider} can construct that provider when a field is
|
|
21
|
+
* bound to it.
|
|
22
|
+
*
|
|
23
|
+
* Registering an already-registered `type` overwrites the previous factory,
|
|
24
|
+
* allowing a host to replace a built-in (e.g. swap the default `'local'`
|
|
25
|
+
* provider) if needed.
|
|
26
|
+
*
|
|
27
|
+
* @typeParam TConfig - The provider-specific config type.
|
|
28
|
+
* @param type - The provider `type` discriminator used in storage config
|
|
29
|
+
* (e.g. `'s3'`, `'vercel-blob'`, `'cloudflare-r2'`).
|
|
30
|
+
* @param factory - A function that builds the provider from its config.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
35
|
+
* import { S3StorageProvider, type S3StorageConfig } from '@opensaas/stack-storage-s3'
|
|
36
|
+
*
|
|
37
|
+
* registerStorageProvider<S3StorageConfig>('s3', (config) => new S3StorageProvider(config))
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function registerStorageProvider<TConfig extends BaseStorageConfig>(type: string, factory: StorageProviderFactory<TConfig>): void;
|
|
41
|
+
/**
|
|
42
|
+
* Returns the factory registered for a provider `type`, or `undefined` when no
|
|
43
|
+
* provider has been registered under that `type`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getStorageProviderFactory(type: string): StorageProviderFactory | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Returns `true` when a provider factory is registered for the given `type`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function hasStorageProvider(type: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Resets the registry to only the built-in defaults.
|
|
52
|
+
*
|
|
53
|
+
* Primarily useful in tests to guarantee isolation between cases that register
|
|
54
|
+
* custom providers.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resetStorageProviderRegistry(): void;
|
|
57
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/runtime/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAsB,MAAM,oBAAoB,CAAA;AAGhG;;;;;;;;;;GAUG;AACH,MAAM,MAAM,sBAAsB,CAAC,OAAO,SAAS,iBAAiB,GAAG,iBAAiB,IAAI,CAC1F,MAAM,EAAE,OAAO,KACZ,eAAe,CAAA;AAYpB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,SAAS,iBAAiB,EACvE,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,sBAAsB,CAAC,OAAO,CAAC,GACvC,IAAI,CAON;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,sBAAsB,GAAG,SAAS,CAE1F;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,IAAI,IAAI,CAGnD"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { LocalStorageProvider } from '../providers/local.js';
|
|
2
|
+
/**
|
|
3
|
+
* Internal registry mapping a provider `type` to its factory.
|
|
4
|
+
*
|
|
5
|
+
* Factories are stored against {@link BaseStorageConfig} because every provider
|
|
6
|
+
* config extends it. `registerStorageProvider` narrows the public input to the
|
|
7
|
+
* concrete config type, then widens it for storage — this conversion happens
|
|
8
|
+
* once, internally, so the public API stays free of `any`/casts.
|
|
9
|
+
*/
|
|
10
|
+
const registry = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Registers a storage provider factory for a given provider `type`.
|
|
13
|
+
*
|
|
14
|
+
* The host application calls this to opt in to a provider it uses — either one
|
|
15
|
+
* of the optional provider packages (`@opensaas/stack-storage-s3`,
|
|
16
|
+
* `@opensaas/stack-storage-vercel`) or a custom provider. Once registered,
|
|
17
|
+
* {@link createStorageProvider} can construct that provider when a field is
|
|
18
|
+
* bound to it.
|
|
19
|
+
*
|
|
20
|
+
* Registering an already-registered `type` overwrites the previous factory,
|
|
21
|
+
* allowing a host to replace a built-in (e.g. swap the default `'local'`
|
|
22
|
+
* provider) if needed.
|
|
23
|
+
*
|
|
24
|
+
* @typeParam TConfig - The provider-specific config type.
|
|
25
|
+
* @param type - The provider `type` discriminator used in storage config
|
|
26
|
+
* (e.g. `'s3'`, `'vercel-blob'`, `'cloudflare-r2'`).
|
|
27
|
+
* @param factory - A function that builds the provider from its config.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
32
|
+
* import { S3StorageProvider, type S3StorageConfig } from '@opensaas/stack-storage-s3'
|
|
33
|
+
*
|
|
34
|
+
* registerStorageProvider<S3StorageConfig>('s3', (config) => new S3StorageProvider(config))
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function registerStorageProvider(type, factory) {
|
|
38
|
+
// `factory` accepts a narrower `TConfig`; the registry stores it against the
|
|
39
|
+
// wider `BaseStorageConfig`. This widening wrapper is safe because
|
|
40
|
+
// `createStorageProvider` only ever invokes a factory with the config whose
|
|
41
|
+
// `type` selected it, so the runtime config always matches `TConfig`.
|
|
42
|
+
const widened = (config) => factory(config);
|
|
43
|
+
registry.set(type, widened);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the factory registered for a provider `type`, or `undefined` when no
|
|
47
|
+
* provider has been registered under that `type`.
|
|
48
|
+
*/
|
|
49
|
+
export function getStorageProviderFactory(type) {
|
|
50
|
+
return registry.get(type);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Returns `true` when a provider factory is registered for the given `type`.
|
|
54
|
+
*/
|
|
55
|
+
export function hasStorageProvider(type) {
|
|
56
|
+
return registry.has(type);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resets the registry to only the built-in defaults.
|
|
60
|
+
*
|
|
61
|
+
* Primarily useful in tests to guarantee isolation between cases that register
|
|
62
|
+
* custom providers.
|
|
63
|
+
*/
|
|
64
|
+
export function resetStorageProviderRegistry() {
|
|
65
|
+
registry.clear();
|
|
66
|
+
registerBuiltinProviders();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Registers the providers that ship with `@opensaas/stack-storage` itself.
|
|
70
|
+
*
|
|
71
|
+
* Currently only `'local'`, which keeps existing behaviour unchanged: hosts get
|
|
72
|
+
* local filesystem storage with no registration step. Optional providers
|
|
73
|
+
* (`s3`, `vercel-blob`) are NOT registered here — the host opts into those to
|
|
74
|
+
* avoid forcing the AWS/Vercel SDKs onto every storage user.
|
|
75
|
+
*/
|
|
76
|
+
function registerBuiltinProviders() {
|
|
77
|
+
registerStorageProvider('local', (config) => new LocalStorageProvider(config));
|
|
78
|
+
}
|
|
79
|
+
// Register built-in providers on module load so `'local'` works by default.
|
|
80
|
+
registerBuiltinProviders();
|
|
81
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/runtime/registry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AAiB5D;;;;;;;GAOG;AACH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkC,CAAA;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,uBAAuB,CACrC,IAAY,EACZ,OAAwC;IAExC,6EAA6E;IAC7E,mEAAmE;IACnE,4EAA4E;IAC5E,sEAAsE;IACtE,MAAM,OAAO,GAA2B,CAAC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,MAAiB,CAAC,CAAA;IAC9E,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;AAC7B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,IAAY;IACpD,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AAC3B,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AAC3B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B;IAC1C,QAAQ,CAAC,KAAK,EAAE,CAAA;IAChB,wBAAwB,EAAE,CAAA;AAC5B,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,wBAAwB;IAC/B,uBAAuB,CAAqB,OAAO,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,oBAAoB,CAAC,MAAM,CAAC,CAAC,CAAA;AACpG,CAAC;AAED,4EAA4E;AAC5E,wBAAwB,EAAE,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "File and image upload field types with pluggable storage providers for OpenSaas Stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@vitest/coverage-v8": "^4.0.18",
|
|
36
36
|
"typescript": "^5.9.3",
|
|
37
37
|
"vitest": "^4.1.0",
|
|
38
|
-
"@opensaas/stack-core": "0.
|
|
38
|
+
"@opensaas/stack-core": "0.25.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@opensaas/stack-core": "^0"
|
package/src/runtime/index.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
2
2
|
import type {
|
|
3
3
|
StorageProvider,
|
|
4
|
-
LocalStorageConfig,
|
|
5
4
|
FileMetadata,
|
|
6
5
|
ImageMetadata,
|
|
7
6
|
ImageTransformationConfig,
|
|
8
7
|
} from '../config/types.js'
|
|
9
|
-
import { LocalStorageProvider } from '../providers/local.js'
|
|
10
8
|
import { validateFile, getMimeType, type FileValidationOptions } from '../utils/upload.js'
|
|
11
9
|
import { getImageDimensions, processImageTransformations } from '../utils/image.js'
|
|
10
|
+
import { getStorageProviderFactory } from './registry.js'
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
|
-
* Creates a storage provider instance from config
|
|
13
|
+
* Creates a storage provider instance from config.
|
|
14
|
+
*
|
|
15
|
+
* The provider `type` is resolved through the provider registry (see
|
|
16
|
+
* {@link registerStorageProvider}) rather than a closed `switch`. `'local'` is
|
|
17
|
+
* registered as a built-in default, so it works with no registration step.
|
|
18
|
+
* Optional providers (`@opensaas/stack-storage-s3`,
|
|
19
|
+
* `@opensaas/stack-storage-vercel`) and custom providers must be registered by
|
|
20
|
+
* the host before they can be constructed.
|
|
21
|
+
*
|
|
22
|
+
* @throws If the named provider is not present in `config.storage`.
|
|
23
|
+
* @throws If no provider factory has been registered for the config's `type`.
|
|
15
24
|
*/
|
|
16
25
|
export function createStorageProvider(
|
|
17
26
|
config: OpenSaasConfig,
|
|
@@ -23,12 +32,16 @@ export function createStorageProvider(
|
|
|
23
32
|
|
|
24
33
|
const providerConfig = config.storage[providerName]
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
const factory = getStorageProviderFactory(providerConfig.type)
|
|
36
|
+
if (!factory) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Unknown storage provider type: ${providerConfig.type}. ` +
|
|
39
|
+
`Register it with registerStorageProvider('${providerConfig.type}', ...) from ` +
|
|
40
|
+
`'@opensaas/stack-storage/runtime' before use.`,
|
|
41
|
+
)
|
|
31
42
|
}
|
|
43
|
+
|
|
44
|
+
return factory(providerConfig)
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
/**
|
|
@@ -241,3 +254,13 @@ export async function deleteImage(config: OpenSaasConfig, metadata: ImageMetadat
|
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
export { parseFileFromFormData } from '../utils/upload.js'
|
|
257
|
+
|
|
258
|
+
// Provider registration API: hosts register optional/custom providers so
|
|
259
|
+
// createStorageProvider can construct them (see registry.ts).
|
|
260
|
+
export {
|
|
261
|
+
registerStorageProvider,
|
|
262
|
+
getStorageProviderFactory,
|
|
263
|
+
hasStorageProvider,
|
|
264
|
+
resetStorageProviderRegistry,
|
|
265
|
+
type StorageProviderFactory,
|
|
266
|
+
} from './registry.js'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { StorageProvider, BaseStorageConfig, LocalStorageConfig } from '../config/types.js'
|
|
2
|
+
import { LocalStorageProvider } from '../providers/local.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A factory that constructs a {@link StorageProvider} from its provider config.
|
|
6
|
+
*
|
|
7
|
+
* The factory is generic over the concrete config shape so a provider package
|
|
8
|
+
* can register with full type safety (e.g. `S3StorageConfig`), while the
|
|
9
|
+
* registry stores factories against the {@link BaseStorageConfig} contract that
|
|
10
|
+
* every provider config satisfies.
|
|
11
|
+
*
|
|
12
|
+
* @typeParam TConfig - The provider-specific config type (must extend
|
|
13
|
+
* {@link BaseStorageConfig}).
|
|
14
|
+
*/
|
|
15
|
+
export type StorageProviderFactory<TConfig extends BaseStorageConfig = BaseStorageConfig> = (
|
|
16
|
+
config: TConfig,
|
|
17
|
+
) => StorageProvider
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Internal registry mapping a provider `type` to its factory.
|
|
21
|
+
*
|
|
22
|
+
* Factories are stored against {@link BaseStorageConfig} because every provider
|
|
23
|
+
* config extends it. `registerStorageProvider` narrows the public input to the
|
|
24
|
+
* concrete config type, then widens it for storage — this conversion happens
|
|
25
|
+
* once, internally, so the public API stays free of `any`/casts.
|
|
26
|
+
*/
|
|
27
|
+
const registry = new Map<string, StorageProviderFactory>()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Registers a storage provider factory for a given provider `type`.
|
|
31
|
+
*
|
|
32
|
+
* The host application calls this to opt in to a provider it uses — either one
|
|
33
|
+
* of the optional provider packages (`@opensaas/stack-storage-s3`,
|
|
34
|
+
* `@opensaas/stack-storage-vercel`) or a custom provider. Once registered,
|
|
35
|
+
* {@link createStorageProvider} can construct that provider when a field is
|
|
36
|
+
* bound to it.
|
|
37
|
+
*
|
|
38
|
+
* Registering an already-registered `type` overwrites the previous factory,
|
|
39
|
+
* allowing a host to replace a built-in (e.g. swap the default `'local'`
|
|
40
|
+
* provider) if needed.
|
|
41
|
+
*
|
|
42
|
+
* @typeParam TConfig - The provider-specific config type.
|
|
43
|
+
* @param type - The provider `type` discriminator used in storage config
|
|
44
|
+
* (e.g. `'s3'`, `'vercel-blob'`, `'cloudflare-r2'`).
|
|
45
|
+
* @param factory - A function that builds the provider from its config.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { registerStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
50
|
+
* import { S3StorageProvider, type S3StorageConfig } from '@opensaas/stack-storage-s3'
|
|
51
|
+
*
|
|
52
|
+
* registerStorageProvider<S3StorageConfig>('s3', (config) => new S3StorageProvider(config))
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function registerStorageProvider<TConfig extends BaseStorageConfig>(
|
|
56
|
+
type: string,
|
|
57
|
+
factory: StorageProviderFactory<TConfig>,
|
|
58
|
+
): void {
|
|
59
|
+
// `factory` accepts a narrower `TConfig`; the registry stores it against the
|
|
60
|
+
// wider `BaseStorageConfig`. This widening wrapper is safe because
|
|
61
|
+
// `createStorageProvider` only ever invokes a factory with the config whose
|
|
62
|
+
// `type` selected it, so the runtime config always matches `TConfig`.
|
|
63
|
+
const widened: StorageProviderFactory = (config) => factory(config as TConfig)
|
|
64
|
+
registry.set(type, widened)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the factory registered for a provider `type`, or `undefined` when no
|
|
69
|
+
* provider has been registered under that `type`.
|
|
70
|
+
*/
|
|
71
|
+
export function getStorageProviderFactory(type: string): StorageProviderFactory | undefined {
|
|
72
|
+
return registry.get(type)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns `true` when a provider factory is registered for the given `type`.
|
|
77
|
+
*/
|
|
78
|
+
export function hasStorageProvider(type: string): boolean {
|
|
79
|
+
return registry.has(type)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resets the registry to only the built-in defaults.
|
|
84
|
+
*
|
|
85
|
+
* Primarily useful in tests to guarantee isolation between cases that register
|
|
86
|
+
* custom providers.
|
|
87
|
+
*/
|
|
88
|
+
export function resetStorageProviderRegistry(): void {
|
|
89
|
+
registry.clear()
|
|
90
|
+
registerBuiltinProviders()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Registers the providers that ship with `@opensaas/stack-storage` itself.
|
|
95
|
+
*
|
|
96
|
+
* Currently only `'local'`, which keeps existing behaviour unchanged: hosts get
|
|
97
|
+
* local filesystem storage with no registration step. Optional providers
|
|
98
|
+
* (`s3`, `vercel-blob`) are NOT registered here — the host opts into those to
|
|
99
|
+
* avoid forcing the AWS/Vercel SDKs onto every storage user.
|
|
100
|
+
*/
|
|
101
|
+
function registerBuiltinProviders(): void {
|
|
102
|
+
registerStorageProvider<LocalStorageConfig>('local', (config) => new LocalStorageProvider(config))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Register built-in providers on module load so `'local'` works by default.
|
|
106
|
+
registerBuiltinProviders()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { OpenSaasConfig } from '@opensaas/stack-core'
|
|
3
|
+
import {
|
|
4
|
+
createStorageProvider,
|
|
5
|
+
registerStorageProvider,
|
|
6
|
+
getStorageProviderFactory,
|
|
7
|
+
hasStorageProvider,
|
|
8
|
+
resetStorageProviderRegistry,
|
|
9
|
+
} from '../src/runtime/index.js'
|
|
10
|
+
import { LocalStorageProvider } from '../src/providers/local.js'
|
|
11
|
+
import { assembleImageMetadata, keystoneImageColumnMap } from '../src/utils/multi-column.js'
|
|
12
|
+
import type {
|
|
13
|
+
StorageProvider,
|
|
14
|
+
UploadOptions,
|
|
15
|
+
UploadResult,
|
|
16
|
+
BaseStorageConfig,
|
|
17
|
+
} from '../src/config/types.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A minimal custom provider config + implementation used to prove that a
|
|
21
|
+
* non-`local`, host-registered provider can be constructed via the registry —
|
|
22
|
+
* without `@opensaas/stack-storage` depending on `-s3`/`-vercel`.
|
|
23
|
+
*/
|
|
24
|
+
interface FakeStorageConfig extends BaseStorageConfig {
|
|
25
|
+
type: 'fake'
|
|
26
|
+
baseUrl: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class FakeStorageProvider implements StorageProvider {
|
|
30
|
+
constructor(public readonly config: FakeStorageConfig) {}
|
|
31
|
+
|
|
32
|
+
async upload(
|
|
33
|
+
_file: Buffer | Uint8Array,
|
|
34
|
+
filename: string,
|
|
35
|
+
options?: UploadOptions,
|
|
36
|
+
): Promise<UploadResult> {
|
|
37
|
+
return {
|
|
38
|
+
filename,
|
|
39
|
+
url: `${this.config.baseUrl}/${filename}`,
|
|
40
|
+
size: 0,
|
|
41
|
+
contentType: options?.contentType ?? 'application/octet-stream',
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async download(): Promise<Buffer> {
|
|
46
|
+
return Buffer.from('')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async delete(): Promise<void> {}
|
|
50
|
+
|
|
51
|
+
getUrl(filename: string): string {
|
|
52
|
+
return `${this.config.baseUrl}/${filename}`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Builds a minimal, strongly-typed config carrying a single storage provider. */
|
|
57
|
+
function configWith(name: string, providerConfig: BaseStorageConfig): OpenSaasConfig {
|
|
58
|
+
return {
|
|
59
|
+
db: { provider: 'sqlite' },
|
|
60
|
+
lists: {},
|
|
61
|
+
storage: { [name]: providerConfig },
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('storage provider registry', () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// Restore registry to built-in defaults so registrations don't leak.
|
|
68
|
+
resetStorageProviderRegistry()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('built-in defaults', () => {
|
|
72
|
+
it("registers 'local' by default so createStorageProvider builds it", () => {
|
|
73
|
+
const config = configWith('files', {
|
|
74
|
+
type: 'local',
|
|
75
|
+
uploadDir: './uploads',
|
|
76
|
+
serveUrl: '/uploads',
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const provider = createStorageProvider(config, 'files')
|
|
80
|
+
|
|
81
|
+
expect(provider).toBeInstanceOf(LocalStorageProvider)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("reports 'local' as registered", () => {
|
|
85
|
+
expect(hasStorageProvider('local')).toBe(true)
|
|
86
|
+
expect(getStorageProviderFactory('local')).toBeTypeOf('function')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('registering a custom provider', () => {
|
|
91
|
+
it('lets createStorageProvider construct a registered non-local provider', () => {
|
|
92
|
+
registerStorageProvider<FakeStorageConfig>(
|
|
93
|
+
'fake',
|
|
94
|
+
(providerConfig) => new FakeStorageProvider(providerConfig),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const config = configWith('cdn', {
|
|
98
|
+
type: 'fake',
|
|
99
|
+
baseUrl: 'https://cdn.example.com',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const provider = createStorageProvider(config, 'cdn')
|
|
103
|
+
|
|
104
|
+
expect(provider).toBeInstanceOf(FakeStorageProvider)
|
|
105
|
+
expect(provider.getUrl('photo.jpg')).toBe('https://cdn.example.com/photo.jpg')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('passes the provider config through to the factory', () => {
|
|
109
|
+
let received: FakeStorageConfig | undefined
|
|
110
|
+
registerStorageProvider<FakeStorageConfig>('fake', (providerConfig) => {
|
|
111
|
+
received = providerConfig
|
|
112
|
+
return new FakeStorageProvider(providerConfig)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const providerConfig: FakeStorageConfig = {
|
|
116
|
+
type: 'fake',
|
|
117
|
+
baseUrl: 'https://assets.example.com',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
createStorageProvider(configWith('assets', providerConfig), 'assets')
|
|
121
|
+
|
|
122
|
+
expect(received).toEqual(providerConfig)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('reflects registration via hasStorageProvider/getStorageProviderFactory', () => {
|
|
126
|
+
expect(hasStorageProvider('fake')).toBe(false)
|
|
127
|
+
|
|
128
|
+
registerStorageProvider<FakeStorageConfig>(
|
|
129
|
+
'fake',
|
|
130
|
+
(providerConfig) => new FakeStorageProvider(providerConfig),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(hasStorageProvider('fake')).toBe(true)
|
|
134
|
+
expect(getStorageProviderFactory('fake')).toBeTypeOf('function')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('overwrites a previous registration for the same type', () => {
|
|
138
|
+
registerStorageProvider<FakeStorageConfig>(
|
|
139
|
+
'fake',
|
|
140
|
+
(providerConfig) => new FakeStorageProvider({ ...providerConfig, baseUrl: 'first' }),
|
|
141
|
+
)
|
|
142
|
+
registerStorageProvider<FakeStorageConfig>(
|
|
143
|
+
'fake',
|
|
144
|
+
(providerConfig) => new FakeStorageProvider({ ...providerConfig, baseUrl: 'second' }),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const provider = createStorageProvider(
|
|
148
|
+
configWith('cdn', { type: 'fake', baseUrl: 'ignored' }),
|
|
149
|
+
'cdn',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
expect(provider.getUrl('x')).toBe('second/x')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('unregistered providers', () => {
|
|
157
|
+
it('throws a clear error for an unregistered type', () => {
|
|
158
|
+
const config = configWith('cdn', { type: 's3', bucket: 'my-bucket' })
|
|
159
|
+
|
|
160
|
+
expect(() => createStorageProvider(config, 'cdn')).toThrow(
|
|
161
|
+
/Unknown storage provider type: s3/,
|
|
162
|
+
)
|
|
163
|
+
expect(() => createStorageProvider(config, 'cdn')).toThrow(/registerStorageProvider/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('still throws when the named provider is absent from config', () => {
|
|
167
|
+
const config = configWith('files', {
|
|
168
|
+
type: 'local',
|
|
169
|
+
uploadDir: './uploads',
|
|
170
|
+
serveUrl: '/uploads',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect(() => createStorageProvider(config, 'missing')).toThrow(
|
|
174
|
+
/Storage provider 'missing' not found in config/,
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('read path is unaffected by the registry', () => {
|
|
180
|
+
it('assembleImageMetadata stamps the provider name without constructing a provider', () => {
|
|
181
|
+
// No provider registered for 'remote'; the read path must not care.
|
|
182
|
+
expect(hasStorageProvider('remote')).toBe(false)
|
|
183
|
+
|
|
184
|
+
const map = keystoneImageColumnMap('image')
|
|
185
|
+
const row = {
|
|
186
|
+
image_url: 'https://cdn.example.com/pic.jpg',
|
|
187
|
+
image_width: 800,
|
|
188
|
+
image_height: 600,
|
|
189
|
+
image_filesize: 1234,
|
|
190
|
+
image_contentType: 'image/jpeg',
|
|
191
|
+
image_pathname: 'pic.jpg',
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const meta = assembleImageMetadata(row, map, 'remote')
|
|
195
|
+
|
|
196
|
+
expect(meta).not.toBeNull()
|
|
197
|
+
expect(meta?.storageProvider).toBe('remote')
|
|
198
|
+
expect(meta?.url).toBe('https://cdn.example.com/pic.jpg')
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
})
|