@felipedsvit/s3node 0.1.2

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.
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createReadStream, createWriteStream } from 'node:fs'
3
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { Readable } from 'node:stream'
6
+ import { pipeline } from 'node:stream/promises'
7
+ import { HashingStream } from '../util/hash.js'
8
+
9
+ const READ_HIGH_WATER_MARK = 1024 * 1024
10
+
11
+ async function fsyncDirectory(directory) {
12
+ let handle
13
+ try {
14
+ handle = await open(directory, 'r')
15
+ await handle.sync()
16
+ } catch {
17
+ // Directory fsync is unavailable on some platforms (notably Windows).
18
+ } finally {
19
+ await handle?.close().catch(() => {})
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Content-addressed blob storage.
25
+ *
26
+ * Blob names are decoupled from object keys. That is what makes `../` in a key
27
+ * harmless: a key never becomes a filesystem path, so the entire path-traversal
28
+ * class is eliminated by construction (docs/plan.md section 7).
29
+ */
30
+ export class BlobStore {
31
+ constructor(root) {
32
+ this.root = root
33
+ this.dataDir = join(root, 'data')
34
+ this.tmpDir = join(root, 'tmp')
35
+ }
36
+
37
+ async init() {
38
+ await mkdir(this.dataDir, { recursive: true })
39
+ await mkdir(this.tmpDir, { recursive: true })
40
+ }
41
+
42
+ /** Two-level fanout keeps directory sizes bounded. */
43
+ path(blobId) {
44
+ return join(this.dataDir, blobId.slice(0, 2), blobId.slice(2, 4), blobId)
45
+ }
46
+
47
+ /**
48
+ * Durable write. The step order matters and is load-bearing: the blob is
49
+ * fully persisted before any metadata references it. A crash mid-sequence
50
+ * leaves an orphan blob (swept later); the reverse order would leave metadata
51
+ * pointing at a missing blob, which is observable data loss.
52
+ */
53
+ async write(source, { algorithms = ['md5'], transforms = [] } = {}) {
54
+ const blobId = randomUUID().replaceAll('-', '')
55
+ const tmpPath = join(this.tmpDir, blobId)
56
+ const hasher = new HashingStream(algorithms)
57
+ // `source` may be a chain (request -> chunked decoder); pipeline propagates
58
+ // errors and destroys every stage, which a bare .pipe() would not.
59
+ const chain = Array.isArray(source) ? source : [source]
60
+
61
+ try {
62
+ // 1-2. Stream to a temp file on the same filesystem, hashing in one pass.
63
+ // Digests are taken over the plaintext, so any encryption transform
64
+ // sits after the hasher — the ETag stays the MD5 clients expect.
65
+ await pipeline(...chain, hasher, ...transforms,
66
+ createWriteStream(tmpPath, { flags: 'wx', mode: 0o600 }))
67
+
68
+ // 3. fsync the file itself.
69
+ const handle = await open(tmpPath, 'r+')
70
+ try {
71
+ await handle.sync()
72
+ } finally {
73
+ await handle.close()
74
+ }
75
+
76
+ // 4-5. Atomically move into place, then fsync the parent directory so the
77
+ // rename survives power loss.
78
+ const finalPath = this.path(blobId)
79
+ await mkdir(dirname(finalPath), { recursive: true })
80
+ await rename(tmpPath, finalPath)
81
+ await fsyncDirectory(dirname(finalPath))
82
+
83
+ return { blobId, size: hasher.bytesWritten, hasher }
84
+ } catch (err) {
85
+ await rm(tmpPath, { force: true }).catch(() => {})
86
+ throw err
87
+ }
88
+ }
89
+
90
+ async remove(blobId) {
91
+ if (!blobId) return
92
+ await rm(this.path(blobId), { force: true }).catch(() => {})
93
+ }
94
+
95
+ async removeMany(blobIds) {
96
+ await Promise.all([...new Set(blobIds)].map((id) => this.remove(id)))
97
+ }
98
+
99
+ async size(blobId) {
100
+ return (await stat(this.path(blobId))).size
101
+ }
102
+
103
+ createReadStream(blobId, { start, end } = {}) {
104
+ return createReadStream(this.path(blobId), {
105
+ start,
106
+ end,
107
+ highWaterMark: READ_HIGH_WATER_MARK,
108
+ })
109
+ }
110
+
111
+ /**
112
+ * Virtual concatenation across a multipart manifest. Parts stay as separate
113
+ * blobs — `CompleteMultipartUpload` writes a manifest instead of rewriting
114
+ * the object, which keeps Complete cheap and range reads efficient
115
+ * (docs/plan.md section 8).
116
+ */
117
+ createRangeStream(parts, start, end) {
118
+ const store = this
119
+ async function* generate() {
120
+ let offset = 0
121
+ for (const part of parts) {
122
+ const partStart = offset
123
+ const partEnd = offset + part.size - 1
124
+ offset += part.size
125
+ if (partEnd < start) continue
126
+ if (partStart > end) break
127
+ const from = Math.max(start - partStart, 0)
128
+ const to = Math.min(end - partStart, part.size - 1)
129
+ if (to < from) continue
130
+ yield* store.createReadStream(part.blobId, { start: from, end: to })
131
+ }
132
+ }
133
+ return Readable.from(generate(), { highWaterMark: READ_HIGH_WATER_MARK })
134
+ }
135
+ }