@nxtedition/lib 19.0.33 → 19.0.35

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 (2) hide show
  1. package/package.json +1 -1
  2. package/s3.js +84 -163
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "19.0.33",
3
+ "version": "19.0.35",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "type": "module",
package/s3.js CHANGED
@@ -1,118 +1,17 @@
1
1
  import crypto from 'node:crypto'
2
2
  import stream from 'node:stream'
3
- import path from 'node:path'
4
- import os from 'node:os'
5
- import fs from 'node:fs'
6
- import assert from 'node:assert'
7
3
  import AWS from '@aws-sdk/client-s3'
8
4
  import PQueue from 'p-queue'
9
5
 
10
6
  const CONTENT_MD5_EXPR = /^[A-F0-9]{32}$/i
11
7
  const CONTENT_LENGTH_EXPR = /^\d+$/i
12
8
 
13
- const noop = (arg0) => {}
14
-
15
- class PartUploader {
16
- #number
17
- #path
18
- #size
19
- #writable
20
- #callback
21
- #hasher
22
- #signal
23
- #logger
24
-
25
- constructor({ dir, number, signal, logger }) {
26
- this.#writable = null
27
- this.#callback = noop
28
- this.#hasher = crypto.createHash('md5')
29
- this.#size = 0
30
- this.#signal = signal
31
- this.#number = number
32
- this.#path = path.join(dir, `${this.#number}.part`)
33
- this.#logger = logger?.child({ part: this.#number, path: this.#path })
34
- }
35
-
36
- get size() {
37
- return this.#size
38
- }
39
-
40
- async write(chunk) {
41
- if (!this.#writable) {
42
- this.#writable = fs
43
- .createWriteStream(this.#path, { signal: this.#signal })
44
- .on('drain', () => {
45
- this.#callback(null)
46
- this.#callback = noop
47
- })
48
- .on('error', (err) => {
49
- this.#callback(err)
50
- this.callback = noop
51
- })
52
-
53
- this.#logger?.debug('created part')
54
- }
55
-
56
- if (this.#writable.errored) {
57
- throw this.#writable.errored
58
- }
59
-
60
- this.#size += chunk.byteLength
61
- this.#hasher.update(chunk)
62
-
63
- if (!this.#writable.write(chunk)) {
64
- await new Promise((resolve, reject) => {
65
- this.#callback = (err) => (err ? reject(err) : resolve(null))
66
- })
67
- this.#signal.throwIfAborted()
68
- }
69
- }
70
-
71
- async end(s3, params) {
72
- try {
73
- if (!this.#writable) {
74
- throw new Error('No data to send')
75
- }
76
-
77
- if (this.#writable.errored) {
78
- throw this.#writable.errored
79
- }
80
-
81
- this.#writable.end()
82
- await stream.promises.finished(this.#writable)
83
-
84
- assert(this.#writable.bytesWritten === this.#size, 'Expected size to match bytesWritten')
85
-
86
- this.#logger?.debug('uploading part')
87
- const { ETag } = await s3.send(
88
- new AWS.UploadPartCommand({
89
- ...params,
90
- ContentMD5: this.#hasher.digest('base64'),
91
- ContentLength: this.#size,
92
- PartNumber: this.#number,
93
- Body: fs.createReadStream(this.#path, { signal: this.#signal }),
94
- }),
95
- )
96
- this.#logger?.debug({ etag: ETag }, 'uploaded part')
97
-
98
- return { part: { ETag, PartNumber: this.#number } }
99
- } catch (err) {
100
- return { error: err }
101
- } finally {
102
- await fs.promises.unlink(this.#writable.path)
103
- this.#logger?.debug('deleted part')
104
- }
105
- }
106
- }
107
-
108
9
  export async function upload({
109
10
  client: s3,
110
- signal: outerSignal,
11
+ signal,
111
12
  logger,
112
- tmpdir = os.tmpdir(),
113
- partSize = 64e6,
114
- queueSize = 4,
115
- leavePartsOnError = false,
13
+ partSize = 16e6,
14
+ queueSize = 2,
116
15
  params,
117
16
  }) {
118
17
  if (s3 == null) {
@@ -141,85 +40,118 @@ export async function upload({
141
40
  throw new Error(`Invalid ContentLength: ${ContentLength}`)
142
41
  }
143
42
 
144
- outerSignal?.throwIfAborted()
145
-
146
43
  const queue = new PQueue({ concurrency: queueSize })
147
44
  const promises = []
148
- const ac = new AbortController()
149
- const signal = ac.signal
150
-
151
- const abort = () => ac.abort()
152
- outerSignal?.addEventListener('abort', abort)
153
45
 
154
46
  let uploadId
155
- let uploadDir
156
47
  try {
157
- uploadDir = await fs.promises.mkdtemp(path.join(tmpdir, 's3-upload-'))
158
- logger?.debug({ uploadDir }, 'created upload directory')
159
- signal.throwIfAborted()
160
-
161
48
  const multipartUploadOutput = await s3.send(
162
- new AWS.CreateMultipartUploadCommand({
163
- Bucket,
164
- Key,
165
- }),
49
+ new AWS.CreateMultipartUploadCommand({ Bucket, Key }),
50
+ { abortSignal: signal },
166
51
  )
167
52
  uploadId = multipartUploadOutput.UploadId
168
53
  logger = logger?.child({ uploadId })
169
54
  logger?.debug('created multipart upload')
170
- signal.throwIfAborted()
171
55
 
172
56
  const uploader = {
173
57
  size: 0,
174
58
  hasher: crypto.createHash('md5'),
175
- part: new PartUploader({ dir: uploadDir, number: 1, signal, logger }),
176
- number: 1,
59
+ part: {
60
+ /** @type {Array<Buffer>} **/ chunks: [],
61
+ hasher: crypto.createHash('md5'),
62
+ size: 0,
63
+ number: 1,
64
+ },
177
65
  }
178
66
 
179
67
  const maybeFlush = (minSize) => {
180
- if (uploader.part.size && (minSize == null || uploader.part.size >= minSize)) {
181
- const part = uploader.part
182
- uploader.part = new PartUploader({
183
- dir: uploadDir,
184
- number: ++uploader.number,
185
- logger,
186
- signal,
187
- })
188
-
189
- const promise = queue.add(() => part.end(s3, { Bucket, Key, UploadId: uploadId }))
190
- promise.catch((err) => {
191
- ac.abort(err)
192
- })
193
- promises.push(promise)
68
+ const { part } = uploader
69
+
70
+ if (!part.size) {
71
+ return
194
72
  }
73
+
74
+ if (minSize != null && part.size < minSize) {
75
+ return
76
+ }
77
+
78
+ const chunks = part.chunks
79
+ const number = part.number
80
+ const size = part.size
81
+ const hasher = part.hasher
82
+
83
+ part.chunks = []
84
+ part.number += 1
85
+ part.size = 0
86
+ part.hasher = crypto.createHash('md5')
87
+
88
+ promises.push(
89
+ queue
90
+ .add(
91
+ async ({ signal }) => {
92
+ logger?.debug({ number, size }, 'part upload started')
93
+ try {
94
+ const { ETag } = await s3.send(
95
+ new AWS.UploadPartCommand({
96
+ Bucket,
97
+ Key,
98
+ UploadId: uploadId,
99
+ ContentMD5: hasher.digest('base64'),
100
+ ContentLength: size,
101
+ PartNumber: number,
102
+ Body: new stream.Readable({
103
+ read() {
104
+ for (const chunk of chunks.splice(0)) {
105
+ this.push(chunk)
106
+ }
107
+ this.push(null)
108
+ },
109
+ }),
110
+ }),
111
+ { abortSignal: signal },
112
+ )
113
+ logger?.debug({ number, size, etag: ETag }, 'part upload completed')
114
+ return { part: { ETag, PartNumber: number } }
115
+ } catch (err) {
116
+ logger?.debug({ err }, 'part upload failed')
117
+ return { error: err }
118
+ }
119
+ },
120
+ { signal },
121
+ )
122
+ .catch((err) => ({ error: err })),
123
+ )
124
+
125
+ return queue.onEmpty()
195
126
  }
196
127
 
197
128
  for await (const chunk of Body) {
198
- signal.throwIfAborted()
129
+ signal?.throwIfAborted()
199
130
 
200
131
  uploader.hasher.update(chunk)
201
132
  uploader.size += chunk.byteLength
202
133
 
203
- const thenable = uploader.part.write(chunk)
134
+ uploader.part.hasher.update(chunk)
135
+ uploader.part.chunks.push(chunk)
136
+ uploader.part.size += chunk.byteLength
137
+
138
+ const thenable = maybeFlush(partSize)
204
139
  if (thenable) {
205
140
  await thenable
206
- signal.throwIfAborted()
207
141
  }
208
-
209
- maybeFlush(partSize)
210
142
  }
211
- maybeFlush()
143
+ await maybeFlush()
212
144
 
213
145
  const parts = []
214
146
  const errors = []
215
147
  for (const { part, error } of await Promise.all(promises)) {
216
148
  if (error) {
217
149
  errors.push(error)
218
- } else {
150
+ } else if (part) {
219
151
  parts.push(part)
220
152
  }
221
153
  }
222
- signal.throwIfAborted()
154
+ signal?.throwIfAborted()
223
155
 
224
156
  if (errors.length > 0) {
225
157
  throw new AggregateError(errors, 'upload failed')
@@ -236,7 +168,9 @@ export async function upload({
236
168
  UploadId: uploadId,
237
169
  MultipartUpload: { Parts: parts },
238
170
  }),
171
+ { abortSignal: signal },
239
172
  )
173
+ signal?.throwIfAborted()
240
174
 
241
175
  const result = {
242
176
  size: uploader.size,
@@ -245,10 +179,6 @@ export async function upload({
245
179
  parts,
246
180
  }
247
181
 
248
- logger?.debug(result, 'completed multipart upload')
249
-
250
- signal.throwIfAborted()
251
-
252
182
  const size = ContentLength != null ? Number(ContentLength) : null
253
183
  const hash = ContentMD5
254
184
 
@@ -260,19 +190,15 @@ export async function upload({
260
190
  throw new Error(`Expected hash ${hash} but got ${result.hash}`)
261
191
  }
262
192
 
193
+ logger?.debug(result, 'completed multipart upload')
194
+
263
195
  return result
264
196
  } catch (err) {
265
- ac.abort(err)
197
+ logger?.error({ err }, 'failed multipart upload')
266
198
 
267
- if (uploadId && !leavePartsOnError) {
199
+ if (uploadId) {
268
200
  try {
269
- await s3.send(
270
- new AWS.AbortMultipartUploadCommand({
271
- Bucket,
272
- Key,
273
- UploadId: uploadId,
274
- }),
275
- )
201
+ await s3.send(new AWS.AbortMultipartUploadCommand({ Bucket, Key, UploadId: uploadId }))
276
202
  logger?.warn('aborted multipart upload')
277
203
  } catch (er) {
278
204
  throw new AggregateError([err, er])
@@ -280,10 +206,5 @@ export async function upload({
280
206
  }
281
207
 
282
208
  throw err
283
- } finally {
284
- outerSignal?.removeEventListener('abort', abort)
285
- if (uploadDir) {
286
- await fs.promises.rmdir(uploadDir, { recursive: true })
287
- }
288
209
  }
289
210
  }