@toa.io/extensions.storages 1.0.0-alpha.21 → 1.0.0-alpha.219

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 (91) hide show
  1. package/package.json +14 -14
  2. package/readme.md +96 -51
  3. package/schemas/annotation.cos.yaml +1 -0
  4. package/schemas/cloudinary.cos.yaml +37 -0
  5. package/schemas/fs.cos.yaml +2 -0
  6. package/schemas/s3.cos.yaml +1 -0
  7. package/schemas/tmp.cos.yaml +0 -1
  8. package/source/Entry.ts +13 -11
  9. package/source/Factory.ts +16 -9
  10. package/source/Provider.ts +22 -19
  11. package/source/Scanner.ts +58 -26
  12. package/source/Secrets.ts +6 -0
  13. package/source/Storage.test.ts +118 -274
  14. package/source/Storage.ts +62 -207
  15. package/source/deployment.ts +52 -16
  16. package/source/errors.ts +3 -0
  17. package/source/index.ts +3 -1
  18. package/source/manifest.ts +7 -6
  19. package/source/providers/Cloudinary.ts +320 -0
  20. package/source/providers/Declaration.ts +2 -1
  21. package/source/providers/FileSystem.ts +79 -25
  22. package/source/providers/S3.ts +81 -80
  23. package/source/providers/Temporary.ts +2 -3
  24. package/source/providers/Test.ts +4 -4
  25. package/source/providers/index.test.ts +102 -76
  26. package/source/providers/index.ts +10 -4
  27. package/source/schemas.ts +1 -0
  28. package/source/test/.env.example +4 -0
  29. package/source/test/arny.jpg +0 -0
  30. package/source/test/lenna.48x48.jpeg +0 -0
  31. package/source/test/plank.mp4 +0 -0
  32. package/source/test/sample.avi +0 -0
  33. package/source/test/sample.wav +0 -0
  34. package/source/test/sport.mp4 +0 -0
  35. package/source/test/util.ts +62 -12
  36. package/transpiled/Entry.d.ts +15 -11
  37. package/transpiled/Factory.js +11 -5
  38. package/transpiled/Factory.js.map +1 -1
  39. package/transpiled/Provider.d.ts +17 -16
  40. package/transpiled/Provider.js +6 -4
  41. package/transpiled/Provider.js.map +1 -1
  42. package/transpiled/Scanner.d.ts +6 -2
  43. package/transpiled/Scanner.js +49 -22
  44. package/transpiled/Scanner.js.map +1 -1
  45. package/transpiled/Secrets.d.ts +5 -0
  46. package/transpiled/Secrets.js +3 -0
  47. package/transpiled/Secrets.js.map +1 -0
  48. package/transpiled/Storage.d.ts +13 -21
  49. package/transpiled/Storage.js +52 -155
  50. package/transpiled/Storage.js.map +1 -1
  51. package/transpiled/deployment.d.ts +1 -1
  52. package/transpiled/deployment.js +43 -16
  53. package/transpiled/deployment.js.map +1 -1
  54. package/transpiled/errors.d.ts +2 -0
  55. package/transpiled/errors.js +6 -0
  56. package/transpiled/errors.js.map +1 -0
  57. package/transpiled/index.d.ts +3 -1
  58. package/transpiled/manifest.js +5 -2
  59. package/transpiled/manifest.js.map +1 -1
  60. package/transpiled/providers/Cloudinary.d.ts +51 -0
  61. package/transpiled/providers/Cloudinary.js +223 -0
  62. package/transpiled/providers/Cloudinary.js.map +1 -0
  63. package/transpiled/providers/Declaration.d.ts +3 -2
  64. package/transpiled/providers/FileSystem.d.ts +15 -7
  65. package/transpiled/providers/FileSystem.js +64 -24
  66. package/transpiled/providers/FileSystem.js.map +1 -1
  67. package/transpiled/providers/S3.d.ts +14 -14
  68. package/transpiled/providers/S3.js +62 -60
  69. package/transpiled/providers/S3.js.map +1 -1
  70. package/transpiled/providers/Temporary.d.ts +1 -2
  71. package/transpiled/providers/Temporary.js +2 -2
  72. package/transpiled/providers/Temporary.js.map +1 -1
  73. package/transpiled/providers/Test.d.ts +3 -3
  74. package/transpiled/providers/Test.js +2 -2
  75. package/transpiled/providers/Test.js.map +1 -1
  76. package/transpiled/providers/index.d.ts +7 -2
  77. package/transpiled/providers/index.js +2 -2
  78. package/transpiled/providers/index.js.map +1 -1
  79. package/transpiled/schemas.d.ts +1 -0
  80. package/transpiled/schemas.js +2 -1
  81. package/transpiled/schemas.js.map +1 -1
  82. package/transpiled/test/util.d.ts +89 -5
  83. package/transpiled/test/util.js +51 -4
  84. package/transpiled/test/util.js.map +1 -1
  85. package/transpiled/tsconfig.tsbuildinfo +1 -1
  86. package/source/providers/FileSystem.test.ts +0 -5
  87. package/source/providers/Memory.ts +0 -41
  88. package/source/providers/S3.test.ts +0 -133
  89. package/transpiled/providers/Memory.d.ts +0 -13
  90. package/transpiled/providers/Memory.js +0 -60
  91. package/transpiled/providers/Memory.js.map +0 -1
@@ -0,0 +1,320 @@
1
+ import { basename, dirname, join } from 'node:path'
2
+ import { Readable } from 'node:stream'
3
+ import { v2 as cloudinary } from 'cloudinary'
4
+ import { console } from 'openspan'
5
+ import { Provider } from '../Provider'
6
+ import { ERR_NOT_FOUND } from '../errors'
7
+ import type { Maybe } from '@toa.io/types'
8
+ import type { Metadata, Stream } from '../Entry'
9
+ import type { Secret, Secrets } from '../Secrets'
10
+ import type { ReadableStream } from 'node:stream/web'
11
+ import type {
12
+ ConfigOptions,
13
+ TransformationOptions,
14
+ UploadApiOptions
15
+ } from 'cloudinary'
16
+
17
+ export type CloudinarySecrets = Secrets<'API_KEY' | 'API_SECRET'>
18
+
19
+ export class Cloudinary extends Provider<CloudinaryOptions> {
20
+ public static override readonly SECRETS: readonly Secret[] = [
21
+ { name: 'API_KEY' },
22
+ { name: 'API_SECRET' }
23
+ ]
24
+
25
+ private readonly type: StorageType
26
+ private readonly eager: TransformationOptions[] = []
27
+ private readonly transformations: Transformation[] = []
28
+ private readonly config: ConfigOptions
29
+ private readonly prefix: string
30
+
31
+ public constructor (options: CloudinaryOptions, secrets?: CloudinarySecrets) {
32
+ super(options, secrets)
33
+
34
+ this.type = options.type
35
+
36
+ if (options.eager !== undefined)
37
+ this.eager = options.eager
38
+
39
+ if (options.transformations !== undefined)
40
+ this.transformations = options.transformations.map((transformation) => {
41
+ const { extension, ...rest } = transformation
42
+
43
+ let expression = extension
44
+
45
+ if (!extension.startsWith('^'))
46
+ expression = '^' + extension
47
+
48
+ if (!extension.endsWith('$'))
49
+ expression = extension + '$'
50
+
51
+ return {
52
+ extension: new RegExp(expression),
53
+ ...rest
54
+ }
55
+ })
56
+
57
+ this.config = {
58
+ cloud_name: options.environment,
59
+ api_key: secrets!.API_KEY,
60
+ api_secret: secrets!.API_SECRET
61
+ }
62
+
63
+ this.prefix = options.prefix ?? '/'
64
+ }
65
+
66
+ public async get (path: string, options?: GetOptions): Promise<Maybe<Stream>> {
67
+ const headers: Record<string, string> = {}
68
+
69
+ if (options?.range !== undefined)
70
+ headers.range = options.range
71
+
72
+ if (options?.agent !== undefined)
73
+ headers['user-agent'] = options.agent
74
+
75
+ const response = await this.fetch(path, { method: 'GET', headers })
76
+
77
+ if (response instanceof Error)
78
+ return ERR_NOT_FOUND
79
+
80
+ const metadata = this.metadata(response)
81
+
82
+ return {
83
+ stream: Readable.fromWeb(response.body as ReadableStream),
84
+ ...metadata
85
+ }
86
+ }
87
+
88
+ public async head (path: string): Promise<Maybe<Metadata>> {
89
+ const response = await this.fetch(path, { method: 'HEAD' })
90
+
91
+ if (response instanceof Error)
92
+ return ERR_NOT_FOUND
93
+
94
+ return this.metadata(response)
95
+ }
96
+
97
+ public async put (path: string, stream: Readable): Promise<void> {
98
+ const id = basename(path)
99
+ const folder = join(this.prefix, dirname(path))
100
+
101
+ await new Promise((resolve, reject) => {
102
+ const options: UploadApiOptions = {
103
+ public_id: id,
104
+ folder,
105
+ eager: this.eager,
106
+ resource_type: this.type
107
+ }
108
+
109
+ console.debug('Uploading to Cloudinary', { path, options })
110
+
111
+ stream.pipe(this.cloudinary().uploader.upload_stream(options,
112
+ (error, result) => {
113
+ if (error !== undefined) reject(error)
114
+ else resolve(result)
115
+ }))
116
+
117
+ stream.on('error', (e) => {
118
+ console.error('Cloudinary stream error', { path, error: e })
119
+ stream.destroy()
120
+
121
+ reject(e)
122
+ })
123
+ })
124
+ }
125
+
126
+ public async commit (): Promise<void> {
127
+ // metadata is read-only
128
+ }
129
+
130
+ public async delete (path: string): Promise<void> {
131
+ const id = join(this.prefix, path)
132
+
133
+ console.debug('Deleting from Cloudinary', { path: id })
134
+
135
+ await this.cloudinary().uploader.destroy(id,
136
+ { resource_type: this.type, invalidate: true })
137
+ }
138
+
139
+ public async move (from: string, to: string): Promise<void | Error> {
140
+ const source = join(this.prefix, from)
141
+ const target = join(this.prefix, to)
142
+
143
+ try {
144
+ await this.cloudinary().uploader.rename(source, target,
145
+ { resource_type: this.type, overwrite: true })
146
+ } catch (error: any) {
147
+ if (error.http_code === 404)
148
+ return ERR_NOT_FOUND
149
+ else
150
+ throw error
151
+ }
152
+ }
153
+
154
+ private async fetch (path: string, options: RequestInit = { method: 'GET' }): Promise<Maybe<Response>> {
155
+ const url = this.url(path)
156
+
157
+ if (url === null)
158
+ return ERR_NOT_FOUND
159
+
160
+ console.debug('Fetching from Cloudinary', {
161
+ method: options.method,
162
+ path,
163
+ url
164
+ })
165
+
166
+ const response = await fetch(url, options).catch((e) => e)
167
+
168
+ if (response instanceof Error || response.ok === false) {
169
+ console.debug('Failed to fetch from Cloudinary', {
170
+ url,
171
+ status: response.status,
172
+ message: response.message
173
+ })
174
+
175
+ return ERR_NOT_FOUND
176
+ } else
177
+ console.debug('Received response from Cloudinary', {
178
+ url,
179
+ status: response.status,
180
+ headers: response.headers
181
+ })
182
+
183
+ return response
184
+ }
185
+
186
+ private url (path: string): string | null {
187
+ const [base, transformation] = this.toTransformation(path)
188
+
189
+ if (base === null)
190
+ return null
191
+
192
+ const id = join(this.prefix, base)
193
+
194
+ return this.cloudinary().url(id, {
195
+ resource_type: this.type,
196
+ transformation,
197
+ version: 2
198
+ })
199
+ }
200
+
201
+ private toTransformation (path: string): [string | null, TransformationOptions[] | undefined] {
202
+ if (this.transformations.length === 0)
203
+ return [path, undefined]
204
+
205
+ const [base, ...extensions] = path.split('.')
206
+ const transformations: TransformationOptions[] = []
207
+
208
+ let t = 0
209
+
210
+ for (const extension of extensions) {
211
+ let found = false
212
+
213
+ for (t; t < this.transformations.length && !found; t++) {
214
+ const { extension: regex, condition, transformation, optional } = this.transformations[t]
215
+
216
+ const match = regex.exec(extension)
217
+
218
+ // eslint-disable-next-line max-depth
219
+ if (match === null)
220
+ if (optional === true)
221
+ continue
222
+ else
223
+ return [null, undefined]
224
+
225
+ const options = Array.isArray(transformation) ? transformation : [transformation]
226
+ const stages = options.map((stage) => this.mapTransformation(stage, match.groups!))
227
+
228
+ found = true
229
+
230
+ if (condition === undefined)
231
+ transformations.push(...stages)
232
+ else {
233
+ transformations.push({ if: condition })
234
+ transformations.push(...stages)
235
+ transformations.push({ if: 'end' })
236
+ }
237
+ }
238
+
239
+ if (!found)
240
+ return [null, undefined]
241
+ }
242
+
243
+ for (t; t < this.transformations.length; t++)
244
+ if (this.transformations[t].optional !== true)
245
+ return [null, undefined]
246
+
247
+ return [base, transformations]
248
+ }
249
+
250
+ private mapTransformation (options: Record<string, unknown>, groups: Record<string, string>): TransformationOptions {
251
+ return Object.fromEntries(Object.entries(options).map(([key, value]) => {
252
+ if (typeof value !== 'string')
253
+ return [key, value]
254
+
255
+ if (value.startsWith('<') && value.endsWith('>'))
256
+ value = groups[value.slice(1, -1)]
257
+
258
+ if (key === 'zoom' && value !== undefined)
259
+ value = Number.parseInt(value as string) / 100
260
+
261
+ if (key === 'fetch_format' && value === 'jpeg')
262
+ value = 'jpg'
263
+
264
+ return [key, value]
265
+ }))
266
+ }
267
+
268
+ private metadata (response: Response): Metadata {
269
+ const size = response.headers.get('content-length') === null
270
+ ? null
271
+ : Number.parseInt(response.headers.get('content-length')!)
272
+
273
+ const created = response.headers.get('date') ?? new Date().toISOString()
274
+ const etag = response.headers.get('etag')
275
+ const checksum = etag === null ? basename(response.url) : etag.slice(1, -1)
276
+ const range = response.headers.get('content-range')
277
+
278
+ return {
279
+ type: response.headers.get('content-type')!,
280
+ size,
281
+ checksum,
282
+ created,
283
+ range: range ?? undefined,
284
+ partial: response.status === 206,
285
+ attributes: {
286
+ url: response.url
287
+ }
288
+ }
289
+ }
290
+
291
+ private cloudinary (): typeof cloudinary {
292
+ cloudinary.config(this.config)
293
+
294
+ return cloudinary
295
+ }
296
+ }
297
+
298
+ interface GetOptions {
299
+ agent?: string
300
+ range?: string
301
+ }
302
+
303
+ export interface CloudinaryOptions {
304
+ environment: string
305
+ type: StorageType
306
+ prefix?: string
307
+ eager?: TransformationOptions[]
308
+ transformations?: TransformationDeclaration[]
309
+ }
310
+
311
+ type TransformationDeclaration = Omit<Transformation, 'extension'> & { extension: string }
312
+
313
+ interface Transformation {
314
+ extension: RegExp
315
+ condition?: string
316
+ transformation: Record<string, unknown> | Array<Record<string, unknown>>
317
+ optional?: boolean
318
+ }
319
+
320
+ type StorageType = 'image' | 'video'
@@ -1,10 +1,11 @@
1
1
  import type { S3Options } from './S3'
2
+ import type { CloudinaryOptions } from './Cloudinary'
2
3
  import type { FileSystemOptions } from './FileSystem'
3
4
  import type { TemporaryOptions } from './Temporary'
4
5
 
5
6
  export type Declaration =
6
7
  ({ provider: 's3' } & S3Options)
8
+ | ({ provider: 'cloudinary' } & CloudinaryOptions)
7
9
  | ({ provider: 'fs' } & FileSystemOptions)
8
10
  | ({ provider: 'tmp' } & TemporaryOptions)
9
- | ({ provider: 'mem' })
10
11
  | ({ provider: 'test' } & TemporaryOptions)
@@ -1,51 +1,105 @@
1
- import { type Readable } from 'node:stream'
2
- import { dirname, join } from 'node:path'
3
1
  import fs from 'node:fs/promises'
2
+ import { createReadStream } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
4
  import { Provider } from '../Provider'
5
- import type { ProviderSecrets } from '../Provider'
5
+ import { ERR_NOT_FOUND } from '../errors'
6
+ import type { Readable } from 'node:stream'
7
+ import type { Maybe } from '@toa.io/types'
8
+ import type { Metadata, Stream } from '../Entry'
6
9
 
7
10
  export interface FileSystemOptions {
8
11
  path: string
12
+ claim?: string
9
13
  }
10
14
 
11
15
  export class FileSystem extends Provider<FileSystemOptions> {
12
- protected readonly path: string
16
+ public override readonly root: string
13
17
 
14
- public constructor (options: FileSystemOptions, secrets?: ProviderSecrets) {
15
- super(options, secrets)
18
+ public constructor (options: FileSystemOptions) {
19
+ super(options)
16
20
 
17
- this.path = options.path
21
+ this.root = options.path
18
22
  }
19
23
 
20
- public async get (path: string): Promise<Readable | null> {
21
- try {
22
- const fd = await fs.open(join(this.path, path))
24
+ public async get (rel: string): Promise<Maybe<Stream>> {
25
+ const path = this.blob(rel)
26
+ const metadata = await this.head(rel)
23
27
 
24
- return fd.createReadStream()
25
- } catch (err) {
26
- if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return null
28
+ if (metadata instanceof Error)
29
+ return metadata
27
30
 
28
- throw err
29
- }
31
+ const stream = createReadStream(path)
32
+
33
+ return { stream, ...metadata }
34
+ }
35
+
36
+ public async head (rel: string): Promise<Maybe<Metadata>> {
37
+ const path = this.meta(rel)
38
+
39
+ return this.try(async () => {
40
+ const contents = await fs.readFile(path, 'utf8')
41
+
42
+ return JSON.parse(contents)
43
+ })
30
44
  }
31
45
 
32
- public async put (rel: string, filename: string, stream: Readable): Promise<void> {
33
- const dir = join(this.path, rel)
34
- const path = join(dir, filename)
46
+ public async put (rel: string, stream: Readable): Promise<void> {
47
+ const path = this.blob(rel)
48
+ const dir = dirname(path)
35
49
 
36
- await fs.mkdir(dirname(path), { recursive: true })
50
+ await fs.mkdir(dir, { recursive: true })
37
51
  await fs.writeFile(path, stream)
38
52
  }
39
53
 
54
+ public async commit (rel: string, metadata: Metadata): Promise<void> {
55
+ const path = this.meta(rel)
56
+
57
+ await fs.writeFile(path, JSON.stringify(metadata), 'utf8')
58
+ }
59
+
40
60
  public async delete (path: string): Promise<void> {
41
- await fs.rm(join(this.path, path), { recursive: true, force: true })
61
+ await Promise.all([
62
+ fs.rm(this.blob(path), { force: true }),
63
+ fs.rm(this.meta(path), { force: true })
64
+ ])
42
65
  }
43
66
 
44
- public async move (from: string, to: string): Promise<void> {
45
- from = join(this.path, from)
46
- to = join(this.path, to)
67
+ public async move (from: string, to: string): Promise<Maybe<void>> {
68
+ const bf = this.blob(from)
69
+ const bt = this.blob(to)
70
+ const mf = this.meta(from)
71
+ const mt = this.meta(to)
47
72
 
48
- await fs.mkdir(dirname(to), { recursive: true })
49
- await fs.rename(from, to)
73
+ await fs.mkdir(dirname(bt), { recursive: true })
74
+
75
+ return await this.try(async () => {
76
+ await Promise.all([
77
+ fs.rename(bf, bt),
78
+ fs.rename(mf, mt)
79
+ ])
80
+ })
81
+ }
82
+
83
+ private blob (rel: string): string {
84
+ return this.join(rel, '.blob')
85
+ }
86
+
87
+ private meta (rel: string): string {
88
+ return this.join(rel, '.meta')
89
+ }
90
+
91
+ private join (rel: string, ext: string): string {
92
+ return join(this.root, rel) + ext
93
+ }
94
+
95
+ private async try<T = void>(action: () => Promise<T>): Promise<Maybe<T>> {
96
+ try {
97
+ return await action()
98
+ } catch (err: NodeJS.ErrnoException | any) {
99
+ if (err?.code === 'ENOENT')
100
+ return ERR_NOT_FOUND
101
+ else
102
+ throw err
103
+ }
50
104
  }
51
105
  }
@@ -3,22 +3,15 @@ import { Blob } from 'node:buffer'
3
3
  import { join } from 'node:path/posix'
4
4
  import assert from 'node:assert'
5
5
  import { Upload } from '@aws-sdk/lib-storage'
6
- import {
7
- S3Client,
8
- GetObjectCommand,
9
- CopyObjectCommand,
10
- DeleteObjectsCommand,
11
- paginateListObjectsV2,
12
- HeadObjectCommand,
13
- NotFound,
14
- DeleteObjectCommand,
15
- NoSuchKey,
16
- type S3ClientConfigType,
17
- type ObjectIdentifier
18
- } from '@aws-sdk/client-s3'
6
+ import * as s3 from '@aws-sdk/client-s3'
19
7
  import * as nodeNativeFetch from 'smithy-node-native-fetch'
20
- import { Provider, type ProviderSecret, type ProviderSecrets } from '../Provider'
8
+ import { console } from 'openspan'
9
+ import { Provider } from '../Provider'
10
+ import { ERR_NOT_FOUND } from '../errors'
21
11
  import type { ReadableStream } from 'node:stream/web'
12
+ import type { Maybe } from '@toa.io/types'
13
+ import type { Metadata, Stream } from '../Entry'
14
+ import type { Secret, Secrets } from '../Secrets'
22
15
 
23
16
  export interface S3Options {
24
17
  bucket: string
@@ -27,23 +20,23 @@ export interface S3Options {
27
20
  endpoint?: string
28
21
  }
29
22
 
30
- type S3Secrets = ProviderSecrets<'ACCESS_KEY_ID' | 'SECRET_ACCESS_KEY'>
23
+ type S3Secrets = Secrets<'ACCESS_KEY_ID' | 'SECRET_ACCESS_KEY'>
31
24
 
32
25
  export class S3 extends Provider<S3Options> {
33
- public static override readonly SECRETS: readonly ProviderSecret[] = [
26
+ public static override readonly SECRETS: readonly Secret[] = [
34
27
  { name: 'ACCESS_KEY_ID', optional: true },
35
28
  { name: 'SECRET_ACCESS_KEY', optional: true }
36
29
  ]
37
30
 
38
- protected readonly bucket: string
39
- protected readonly client: S3Client
31
+ private readonly bucket: string
32
+ private readonly client: s3.S3Client
40
33
 
41
34
  public constructor (options: S3Options, secrets?: S3Secrets) {
42
- super(options)
35
+ super(options, secrets)
43
36
 
44
37
  this.bucket = options.bucket
45
38
 
46
- const s3Config: S3ClientConfigType = {
39
+ const s3Config: s3.S3ClientConfigType = {
47
40
  retryMode: 'adaptive',
48
41
  ...nodeNativeFetch
49
42
  }
@@ -53,7 +46,8 @@ export class S3 extends Provider<S3Options> {
53
46
  s3Config.endpoint = options.endpoint
54
47
  }
55
48
 
56
- if (options.region !== undefined) s3Config.region = options.region
49
+ if (options.region !== undefined)
50
+ s3Config.region = options.region
57
51
 
58
52
  if (typeof secrets?.ACCESS_KEY_ID === 'string') {
59
53
  assert.ok(secrets.SECRET_ACCESS_KEY !== undefined,
@@ -65,15 +59,15 @@ export class S3 extends Provider<S3Options> {
65
59
  }
66
60
  }
67
61
 
68
- this.client = new S3Client(s3Config)
62
+ this.client = new s3.S3Client(s3Config)
69
63
 
70
64
  this.client.middlewareStack.add((next, _context) => async (args) => {
71
- if ('Key' in args.input && typeof args.input.Key === 'string')
72
65
  // removes leading slash
66
+ if ('Key' in args.input && typeof args.input.Key === 'string')
73
67
  args.input.Key = args.input.Key.replace(/^\//, '')
74
68
 
75
- if ('Prefix' in args.input && typeof args.input.Prefix === 'string')
76
69
  // removes leading slash and ensures finishing slash
70
+ if ('Prefix' in args.input && typeof args.input.Prefix === 'string')
77
71
  args.input.Prefix = args.input.Prefix.replace(/^\/|\/$/g, '') + '/'
78
72
 
79
73
  return next(args)
@@ -85,82 +79,89 @@ export class S3 extends Provider<S3Options> {
85
79
  })
86
80
  }
87
81
 
88
- public async get (Key: string): Promise<Readable | null> {
89
- try {
90
- const fileResponse = await this.client.send(new GetObjectCommand({
82
+ public async get (Key: string): Promise<Maybe<Stream>> {
83
+ return await this.try<Stream>(async () => {
84
+ const entry = await this.client.send(new s3.GetObjectCommand({
91
85
  Bucket: this.bucket,
92
86
  Key
93
87
  }))
94
88
 
95
- if (fileResponse.Body === undefined) return null // should never happen
89
+ const stream = entry.Body instanceof Readable
90
+ ? entry.Body
91
+ : Readable.fromWeb((entry.Body instanceof Blob
92
+ ? entry.Body.stream()
93
+ : entry.Body) as ReadableStream)
96
94
 
97
- if (fileResponse.Body instanceof Readable) return fileResponse.Body
95
+ if (entry.Metadata?.value === undefined)
96
+ return ERR_NOT_FOUND
98
97
 
99
- return Readable.fromWeb((fileResponse.Body instanceof Blob
100
- ? fileResponse.Body.stream()
101
- : fileResponse.Body) as ReadableStream) // types mismatch between Node 20 and aws-sdk
102
- } catch (err) {
103
- if (err instanceof NotFound || err instanceof NoSuchKey) return null
104
- else throw err
105
- }
98
+ const metadata = JSON.parse(entry.Metadata.value)
99
+
100
+ return { stream, ...metadata }
101
+ })
102
+ }
103
+
104
+ public async head (Key: string): Promise<Maybe<Metadata>> {
105
+ return await this.try<Metadata>(async () => {
106
+ const entry = await this.client.send(new s3.HeadObjectCommand({
107
+ Bucket: this.bucket,
108
+ Key
109
+ }))
110
+
111
+ if (entry.Metadata?.value === undefined)
112
+ return ERR_NOT_FOUND
113
+
114
+ return JSON.parse(entry.Metadata.value)
115
+ })
106
116
  }
107
117
 
108
- public async put (path: string, filename: string, stream: Readable): Promise<void> {
118
+ public async put (Key: string, stream: Readable): Promise<void> {
109
119
  await new Upload({
110
120
  client: this.client,
111
121
  params: {
112
122
  Bucket: this.bucket,
113
- Key: join(path, filename),
123
+ Key,
114
124
  Body: stream
115
125
  }
116
126
  }).done()
117
127
  }
118
128
 
119
- /**
120
- * Deletes either a single object or "directory" - all objects
121
- * with given prefix (prefix will be enforced to finish with `/`)
122
- * @param Key - key name or path prefix
123
- */
124
- public async delete (Key: string): Promise<void> {
125
- const { client, bucket: Bucket } = this
126
-
127
- // checking if given key is a single file
128
- if (!Key.endsWith('/'))
129
- try {
130
- // DeleteObject on S3 returns no error if object does not exist
131
- await client.send(new HeadObjectCommand({ Bucket, Key }))
132
- await client.send(new DeleteObjectCommand({ Bucket, Key }))
133
-
134
- return
135
- } catch (err) {
136
- assert.ok(err instanceof NotFound || err instanceof NoSuchKey, err as Error)
137
- }
129
+ public async commit (Key: string, metadata: object): Promise<void> {
130
+ await this.client.send(new s3.CopyObjectCommand({
131
+ Bucket: this.bucket,
132
+ Key,
133
+ CopySource: join(this.bucket, Key),
134
+ Metadata: { value: JSON.stringify(metadata) },
135
+ MetadataDirective: 'REPLACE'
136
+ }))
138
137
 
139
- const objectsToRemove: ObjectIdentifier[] = []
140
-
141
- for await (const page of paginateListObjectsV2({ client }, { Bucket, Prefix: Key }))
142
- for (const { Key } of page.Contents ?? []) objectsToRemove.push({ Key })
143
-
144
- // Removing all objects in parallel in batches
145
- await Promise.all((function * () {
146
- while (objectsToRemove.length > 0)
147
- yield client.send(new DeleteObjectsCommand({
148
- Bucket,
149
- Delete: {
150
- Objects: objectsToRemove.splice(0,
151
- 1000 /* max batch size for DeleteObjects */)
152
- }
153
- }))
154
- })())
138
+ console.debug('Uploaded to S3', { bucket: this.bucket, path: Key, metadata })
155
139
  }
156
140
 
157
- public async move (from: string, keyTo: string): Promise<void> {
158
- await this.client.send(new CopyObjectCommand({
159
- Bucket: this.bucket,
160
- Key: keyTo,
161
- CopySource: join(this.bucket, from)
162
- }))
141
+ public async delete (Key: string): Promise<void> {
142
+ await this.client.send(new s3.DeleteObjectCommand({ Bucket: this.bucket, Key }))
143
+ }
163
144
 
164
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: from }))
145
+ public async move (from: string, keyTo: string): Promise<Maybe<void>> {
146
+ return await this.try(async () => {
147
+ await this.client.send(new s3.CopyObjectCommand({
148
+ Bucket: this.bucket,
149
+ Key: keyTo,
150
+ CopySource: join(this.bucket, from)
151
+ }))
152
+
153
+ await this.client.send(new s3.DeleteObjectCommand({ Bucket: this.bucket, Key: from }))
154
+ })
155
+ }
156
+
157
+ private async try<T = void>(action: () => Promise<T>): Promise<Maybe<T>> {
158
+ try {
159
+ return await action()
160
+ } catch (err: any) {
161
+ if (err?.name === 'NotFound' || err?.name === 'NoSuchKey')
162
+ return ERR_NOT_FOUND
163
+ else
164
+ throw err
165
+ }
165
166
  }
166
167
  }