s3.js (12418B)
1 // src/lib/storage/s3.js - S3 compatible object storage 2 // 3 // Engaged as soon as storage.s3.bucket is configured. Tested against Garage 4 // and MinIO, which both need path style addressing; set force_path_style to 5 // false for AWS S3 proper. 6 // 7 // Requests go out over fetch with SigV4 headers. 8 // 9 // Large uploads go through multipart, one part at a time. Handing a stream 10 // to fetch does not stream it: undici collects the whole body before 11 // sending, which was measured at just over a gigabyte of live heap for a 12 // one gigabyte artifact. Build output is routinely that size, so a single 13 // upload could exhaust the conductor. Multipart also lifts the five 14 // gigabyte ceiling on a single PUT, and lets an upload proceed when the 15 // total length is not known in advance. 16 // 17 // Small bodies still go as one request, since three round trips to save 18 // buffering a few megabytes is a poor trade. 19 20 import crypto from 'node:crypto'; 21 import { Readable } from 'node:stream'; 22 import { signRequest, presignUrl, sha256Hex, uriEncode } from './sigv4.js'; 23 import { StorageNotFound, assertKey } from './key.js'; 24 25 // Replays what has already been read, then continues with the rest. 26 async function* concatSources(head, rest) { 27 yield head; 28 yield* rest; 29 } 30 31 // S3 requires every part except the last to be at least 5 MiB, and allows 32 // at most 10000 parts. The default part size is comfortably above the 33 // minimum, and is scaled up when the total is large enough that 10000 34 // parts would not cover it. 35 const MIN_PART_SIZE = 5 * 1024 * 1024; 36 const DEFAULT_PART_SIZE = 16 * 1024 * 1024; 37 const MAX_PARTS = 10000; 38 39 // Below this a stream is collected and sent as one request. 40 const SINGLE_PUT_MAX = 32 * 1024 * 1024; 41 42 export function partSizeFor(size) { 43 if (!Number.isFinite(size) || size <= 0) return DEFAULT_PART_SIZE; 44 const needed = Math.ceil(size / (MAX_PARTS - 1)); 45 const rounded = Math.ceil(needed / (1024 * 1024)) * 1024 * 1024; 46 return Math.max(DEFAULT_PART_SIZE, MIN_PART_SIZE, rounded); 47 } 48 49 // Regroups an arbitrarily chunked stream into buffers of a fixed size, so 50 // memory stays bounded by the part size no matter how the source behaves. 51 export async function* inParts(source, partSize) { 52 let held = []; 53 let heldLength = 0; 54 55 for await (const chunk of source) { 56 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); 57 held.push(buffer); 58 heldLength += buffer.length; 59 60 while (heldLength >= partSize) { 61 const joined = Buffer.concat(held, heldLength); 62 yield joined.subarray(0, partSize); 63 const rest = joined.subarray(partSize); 64 held = rest.length > 0 ? [rest] : []; 65 heldLength = rest.length; 66 } 67 } 68 69 if (heldLength > 0) yield Buffer.concat(held, heldLength); 70 } 71 72 // Values inside S3 XML are hex digests and quotes, but escaping keeps a 73 // surprising key or ETag from breaking the document. 74 function xmlEscape(value) { 75 return String(value).replace(/[<>&'"]/g, (c) => ( 76 { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c] 77 )); 78 } 79 80 function firstTag(xml, tag) { 81 const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml); 82 return match ? match[1] : null; 83 } 84 85 export function createS3Storage(cfg) { 86 const s3 = cfg.storage.s3; 87 const endpoint = new URL(s3.endpoint); 88 const creds = { 89 region: s3.region, 90 accessKeyId: s3.access_key_id, 91 secretAccessKey: s3.secret_access_key, 92 }; 93 94 // Any path prefix on the endpoint is preserved, so an S3 gateway mounted 95 // under a subpath keeps working. 96 const prefix = endpoint.pathname.replace(/\/+$/, ''); 97 98 function objectUrl(key) { 99 assertKey(key); 100 const url = new URL(endpoint.toString()); 101 // The signer treats the path as final, so encode it here and exactly 102 // once. Slashes stay literal to keep the key hierarchy intact. 103 const encoded = uriEncode(key, false); 104 if (s3.force_path_style) { 105 url.pathname = `${prefix}/${s3.bucket}/${encoded}`; 106 } else { 107 url.host = `${s3.bucket}.${endpoint.host}`; 108 url.pathname = `${prefix}/${encoded}`; 109 } 110 return url; 111 } 112 113 async function send(method, key, { headers = {}, body, payloadHash, query } = {}) { 114 const url = objectUrl(key); 115 // Query parameters are part of the signature, so they must be set 116 // before signing and not touched afterwards. 117 for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, value); 118 119 const signed = signRequest({ 120 method, 121 url, 122 headers, 123 payloadHash: payloadHash ?? sha256Hex(''), 124 ...creds, 125 }); 126 127 const init = { method, headers: signed }; 128 if (body !== undefined) init.body = body; 129 return fetch(url, init); 130 } 131 132 async function errorFrom(res, action, key) { 133 let detail = ''; 134 try { 135 detail = (await res.text()).slice(0, 512); 136 } catch { 137 // The status alone is still useful. 138 } 139 return new Error(`s3 ${action} failed for ${key}: ${res.status} ${res.statusText}\n${detail}`); 140 } 141 142 // One request, for a body already in memory. 143 async function putBuffer(key, buffer, contentType) { 144 const digest = crypto.createHash('sha256').update(buffer).digest('hex'); 145 const headers = { 'content-length': String(buffer.length) }; 146 if (contentType) headers['content-type'] = contentType; 147 148 const res = await send('PUT', key, { headers, body: buffer, payloadHash: digest }); 149 if (!res.ok) throw await errorFrom(res, 'put', key); 150 await res.arrayBuffer(); 151 152 return { key, size: buffer.length, sha256: digest }; 153 } 154 155 async function createMultipart(key, contentType) { 156 const headers = {}; 157 if (contentType) headers['content-type'] = contentType; 158 159 const res = await send('POST', key, { headers, query: { uploads: '' }, body: undefined }); 160 if (!res.ok) throw await errorFrom(res, 'create multipart upload', key); 161 162 const uploadId = firstTag(await res.text(), 'UploadId'); 163 if (!uploadId) throw new Error(`s3 did not return an upload id for ${key}`); 164 return uploadId; 165 } 166 167 async function uploadPart(key, uploadId, partNumber, buffer) { 168 const res = await send('PUT', key, { 169 headers: { 'content-length': String(buffer.length) }, 170 query: { partNumber: String(partNumber), uploadId }, 171 body: buffer, 172 payloadHash: crypto.createHash('sha256').update(buffer).digest('hex'), 173 }); 174 if (!res.ok) throw await errorFrom(res, `upload part ${partNumber}`, key); 175 await res.arrayBuffer(); 176 177 const etag = res.headers.get('etag'); 178 if (!etag) throw new Error(`s3 did not return an ETag for part ${partNumber} of ${key}`); 179 return etag; 180 } 181 182 async function completeMultipart(key, uploadId, parts) { 183 const body = Buffer.from( 184 '<CompleteMultipartUpload>' + 185 parts.map((p) => `<Part><PartNumber>${p.number}</PartNumber><ETag>${xmlEscape(p.etag)}</ETag></Part>`).join('') + 186 '</CompleteMultipartUpload>', 187 'utf8' 188 ); 189 190 const res = await send('POST', key, { 191 headers: { 'content-length': String(body.length), 'content-type': 'application/xml' }, 192 query: { uploadId }, 193 body, 194 payloadHash: crypto.createHash('sha256').update(body).digest('hex'), 195 }); 196 if (!res.ok) throw await errorFrom(res, 'complete multipart upload', key); 197 198 // S3 can report failure inside a 200 response, because the connection 199 // is held open while the parts are assembled. 200 const text = await res.text(); 201 if (/<Error>/.test(text)) { 202 const code = firstTag(text, 'Code') ?? 'unknown'; 203 throw new Error(`s3 complete multipart upload failed for ${key}: ${code}`); 204 } 205 } 206 207 async function abortMultipart(key, uploadId) { 208 // Parts left behind are billable and invisible, so this matters even 209 // though nothing depends on its result. 210 const res = await send('DELETE', key, { query: { uploadId } }); 211 await res.arrayBuffer().catch(() => {}); 212 } 213 214 async function putMultipart(key, source, { contentType, size }) { 215 const uploadId = await createMultipart(key, contentType); 216 const partSize = partSizeFor(size); 217 const hash = crypto.createHash('sha256'); 218 const parts = []; 219 let total = 0; 220 221 // Parts go up one at a time. Overlapping them was measured at about 222 // eight percent faster while doubling peak memory, which is not a 223 // trade worth the extra failure handling. 224 try { 225 for await (const part of inParts(source, partSize)) { 226 hash.update(part); 227 total += part.length; 228 if (parts.length >= MAX_PARTS) { 229 throw new Error(`s3 upload of ${key} exceeded ${MAX_PARTS} parts`); 230 } 231 const etag = await uploadPart(key, uploadId, parts.length + 1, part); 232 parts.push({ number: parts.length + 1, etag }); 233 } 234 235 // A multipart upload must have at least one part. 236 if (parts.length === 0) { 237 parts.push({ number: 1, etag: await uploadPart(key, uploadId, 1, Buffer.alloc(0)) }); 238 } 239 240 if (size !== undefined && size !== total) { 241 throw new Error(`s3 put size mismatch for ${key}: declared ${size}, read ${total}`); 242 } 243 244 await completeMultipart(key, uploadId, parts); 245 } catch (e) { 246 await abortMultipart(key, uploadId).catch(() => {}); 247 throw e; 248 } 249 250 return { key, size: total, sha256: hash.digest('hex') }; 251 } 252 253 return { 254 driver: 's3', 255 256 async put(key, body, opts = {}) { 257 assertKey(key); 258 259 if (Buffer.isBuffer(body) || typeof body === 'string') { 260 return putBuffer(key, Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'), opts.contentType); 261 } 262 263 const source = body instanceof Readable ? body : Readable.from(body); 264 265 // Small and of known length: collect it and send one request rather 266 // than paying for three. 267 if (opts.size !== undefined && opts.size <= SINGLE_PUT_MAX) { 268 const chunks = []; 269 let length = 0; 270 for await (const chunk of source) { 271 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); 272 chunks.push(buffer); 273 length += buffer.length; 274 if (length > SINGLE_PUT_MAX) break; 275 } 276 277 if (length <= SINGLE_PUT_MAX) { 278 const buffer = Buffer.concat(chunks, length); 279 if (opts.size !== buffer.length) { 280 throw new Error(`s3 put size mismatch for ${key}: declared ${opts.size}, read ${buffer.length}`); 281 } 282 return putBuffer(key, buffer, opts.contentType); 283 } 284 285 // It was larger than it claimed; carry on as multipart without 286 // losing what has already been read. 287 const head = Buffer.concat(chunks, length); 288 return putMultipart(key, concatSources(head, source), { 289 contentType: opts.contentType, 290 size: opts.size, 291 }); 292 } 293 294 return putMultipart(key, source, { contentType: opts.contentType, size: opts.size }); 295 }, 296 297 async get(key, opts = {}) { 298 const headers = {}; 299 if (opts.range) { 300 const { start = 0, end } = opts.range; 301 headers.range = `bytes=${start}-${end === undefined ? '' : end}`; 302 } 303 304 const res = await send('GET', key, { headers }); 305 if (res.status === 404) throw new StorageNotFound(key); 306 if (!res.ok) throw await errorFrom(res, 'get', key); 307 308 const len = res.headers.get('content-length'); 309 return { 310 key, 311 size: len === null ? null : Number(len), 312 stream: Readable.fromWeb(res.body), 313 }; 314 }, 315 316 async head(key) { 317 const res = await send('HEAD', key); 318 if (res.status === 404) return null; 319 if (!res.ok) throw await errorFrom(res, 'head', key); 320 const len = res.headers.get('content-length'); 321 const modified = res.headers.get('last-modified'); 322 return { 323 key, 324 size: len === null ? null : Number(len), 325 modified: modified ? Date.parse(modified) : null, 326 }; 327 }, 328 329 async delete(key) { 330 const res = await send('DELETE', key); 331 // A missing object is already in the desired state. 332 if (!res.ok && res.status !== 404) throw await errorFrom(res, 'delete', key); 333 await res.arrayBuffer(); 334 }, 335 336 // Lets a download redirect straight at the object store 337 // instead of proxying the bytes. 338 async presign(key, opts = {}) { 339 return presignUrl({ 340 method: opts.method || 'GET', 341 url: objectUrl(key), 342 expires: opts.expires ?? 3600, 343 ...creds, 344 }); 345 }, 346 }; 347 }