@nxtedition/lib 19.0.34 → 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 +96 -161
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "19.0.34",
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,116 +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 { ETag, PartNumber: this.#number }
99
- } finally {
100
- await fs.promises.unlink(this.#writable.path)
101
- this.#logger?.debug('deleted part')
102
- }
103
- }
104
- }
105
-
106
9
  export async function upload({
107
10
  client: s3,
108
- signal: outerSignal,
11
+ signal,
109
12
  logger,
110
- tmpdir = os.tmpdir(),
111
- partSize = 64e6,
112
- queueSize = 4,
113
- leavePartsOnError = false,
13
+ partSize = 16e6,
14
+ queueSize = 2,
114
15
  params,
115
16
  }) {
116
17
  if (s3 == null) {
@@ -139,77 +40,122 @@ export async function upload({
139
40
  throw new Error(`Invalid ContentLength: ${ContentLength}`)
140
41
  }
141
42
 
142
- outerSignal?.throwIfAborted()
143
-
144
43
  const queue = new PQueue({ concurrency: queueSize })
145
44
  const promises = []
146
- const ac = new AbortController()
147
- const signal = ac.signal
148
-
149
- const abort = () => ac.abort()
150
- outerSignal?.addEventListener('abort', abort)
151
45
 
152
46
  let uploadId
153
- let uploadDir
154
47
  try {
155
- uploadDir = await fs.promises.mkdtemp(path.join(tmpdir, 's3-upload-'))
156
- logger?.debug({ uploadDir }, 'created upload directory')
157
- signal.throwIfAborted()
158
-
159
48
  const multipartUploadOutput = await s3.send(
160
- new AWS.CreateMultipartUploadCommand({
161
- Bucket,
162
- Key,
163
- }),
49
+ new AWS.CreateMultipartUploadCommand({ Bucket, Key }),
50
+ { abortSignal: signal },
164
51
  )
165
52
  uploadId = multipartUploadOutput.UploadId
166
53
  logger = logger?.child({ uploadId })
167
54
  logger?.debug('created multipart upload')
168
- signal.throwIfAborted()
169
55
 
170
56
  const uploader = {
171
57
  size: 0,
172
58
  hasher: crypto.createHash('md5'),
173
- part: new PartUploader({ dir: uploadDir, number: 1, signal, logger }),
174
- number: 1,
59
+ part: {
60
+ /** @type {Array<Buffer>} **/ chunks: [],
61
+ hasher: crypto.createHash('md5'),
62
+ size: 0,
63
+ number: 1,
64
+ },
175
65
  }
176
66
 
177
67
  const maybeFlush = (minSize) => {
178
- if (uploader.part.size && (minSize == null || uploader.part.size >= minSize)) {
179
- const part = uploader.part
180
- uploader.part = new PartUploader({
181
- dir: uploadDir,
182
- number: ++uploader.number,
183
- logger,
184
- signal,
185
- })
186
-
187
- const promise = queue.add(() => part.end(s3, { Bucket, Key, UploadId: uploadId }))
188
- promise.catch((err) => {
189
- ac.abort(err)
190
- })
191
- promises.push(promise)
68
+ const { part } = uploader
69
+
70
+ if (!part.size) {
71
+ return
72
+ }
73
+
74
+ if (minSize != null && part.size < minSize) {
75
+ return
192
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()
193
126
  }
194
127
 
195
128
  for await (const chunk of Body) {
196
- signal.throwIfAborted()
129
+ signal?.throwIfAborted()
197
130
 
198
131
  uploader.hasher.update(chunk)
199
132
  uploader.size += chunk.byteLength
200
133
 
201
- 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)
202
139
  if (thenable) {
203
140
  await thenable
204
- signal.throwIfAborted()
205
141
  }
206
-
207
- maybeFlush(partSize)
208
142
  }
209
- maybeFlush()
143
+ await maybeFlush()
144
+
145
+ const parts = []
146
+ const errors = []
147
+ for (const { part, error } of await Promise.all(promises)) {
148
+ if (error) {
149
+ errors.push(error)
150
+ } else if (part) {
151
+ parts.push(part)
152
+ }
153
+ }
154
+ signal?.throwIfAborted()
210
155
 
211
- const parts = await Promise.all(promises)
212
- signal.throwIfAborted()
156
+ if (errors.length > 0) {
157
+ throw new AggregateError(errors, 'upload failed')
158
+ }
213
159
 
214
160
  if (parts.length === 0) {
215
161
  throw new Error('upload empty')
@@ -222,7 +168,9 @@ export async function upload({
222
168
  UploadId: uploadId,
223
169
  MultipartUpload: { Parts: parts },
224
170
  }),
171
+ { abortSignal: signal },
225
172
  )
173
+ signal?.throwIfAborted()
226
174
 
227
175
  const result = {
228
176
  size: uploader.size,
@@ -231,10 +179,6 @@ export async function upload({
231
179
  parts,
232
180
  }
233
181
 
234
- logger?.debug(result, 'completed multipart upload')
235
-
236
- signal.throwIfAborted()
237
-
238
182
  const size = ContentLength != null ? Number(ContentLength) : null
239
183
  const hash = ContentMD5
240
184
 
@@ -246,19 +190,15 @@ export async function upload({
246
190
  throw new Error(`Expected hash ${hash} but got ${result.hash}`)
247
191
  }
248
192
 
193
+ logger?.debug(result, 'completed multipart upload')
194
+
249
195
  return result
250
196
  } catch (err) {
251
- ac.abort(err)
197
+ logger?.error({ err }, 'failed multipart upload')
252
198
 
253
- if (uploadId && !leavePartsOnError) {
199
+ if (uploadId) {
254
200
  try {
255
- await s3.send(
256
- new AWS.AbortMultipartUploadCommand({
257
- Bucket,
258
- Key,
259
- UploadId: uploadId,
260
- }),
261
- )
201
+ await s3.send(new AWS.AbortMultipartUploadCommand({ Bucket, Key, UploadId: uploadId }))
262
202
  logger?.warn('aborted multipart upload')
263
203
  } catch (er) {
264
204
  throw new AggregateError([err, er])
@@ -266,10 +206,5 @@ export async function upload({
266
206
  }
267
207
 
268
208
  throw err
269
- } finally {
270
- outerSignal?.removeEventListener('abort', abort)
271
- if (uploadDir) {
272
- await fs.promises.rmdir(uploadDir, { recursive: true })
273
- }
274
209
  }
275
210
  }