-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathuploader.ts
More file actions
331 lines (287 loc) · 10.1 KB
/
Copy pathuploader.ts
File metadata and controls
331 lines (287 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import path from 'node:path'
import { Readable } from 'node:stream'
import { OnDemandFunding } from '@ardrive/turbo-sdk'
import * as mime from 'mime-types'
import pLimit from 'p-limit'
import {
getAllFiles,
getCachedTransaction,
hashFile,
setCachedTransaction,
touchCacheEntry,
type TransactionCache,
} from './cache.js'
import type { UploadClient, UploadCost, UploadSize } from './upload-types.js'
type DataItemTag = { name: string; value: string }
/**
* Provenance tags stamped on every uploaded data item. In CI (GitHub Actions)
* the deploying commit SHA is attached as a GIT-HASH tag; locally, where
* GITHUB_SHA is unset, it is omitted.
*/
export function provenanceTags(): DataItemTag[] {
const tags: DataItemTag[] = [{ name: 'App-Name', value: 'ARIO-Deploy' }]
if (process.env.GITHUB_SHA) {
tags.push({ name: 'GIT-HASH', value: process.env.GITHUB_SHA })
}
return tags
}
export interface UploadResult {
cacheHit: boolean
cost?: UploadCost
size?: UploadSize
transactionId: string
updatedCache?: TransactionCache
}
export interface FolderUploadResult extends UploadResult {
/** Number of files that were cache hits (not re-uploaded) */
cacheHits: number
/** Total number of files in the folder */
totalFiles: number
/** Number of files that were uploaded */
uploaded: number
}
export async function uploadFile(
turbo: UploadClient,
filePath: string,
options?: {
cache?: TransactionCache
fundingMode?: OnDemandFunding
},
): Promise<UploadResult> {
const mimeType = mime.lookup(filePath) || 'application/octet-stream'
// Compute hash if cache is provided
const fileHash = options?.cache ? await hashFile(filePath) : undefined
// Check cache for hit
if (fileHash && options?.cache) {
const cached = getCachedTransaction(options.cache, fileHash)
if (cached) {
const updatedCache = touchCacheEntry(options.cache, fileHash)
return {
cacheHit: true,
transactionId: cached.transactionId,
updatedCache,
}
}
}
// Upload file
const uploadResult = await turbo.uploadFile({
dataItemOpts: {
tags: [
...provenanceTags(),
{
name: 'anchor',
value: new Date().toISOString(),
},
{
name: 'Content-Type',
value: mimeType,
},
],
},
file: filePath,
...(options?.fundingMode && { fundingMode: options.fundingMode }),
})
if (!uploadResult?.id) {
throw new Error('Failed to upload file: upload result missing transaction ID')
}
// Store in cache if provided
if (fileHash && options?.cache) {
const updatedCache = setCachedTransaction(options.cache, fileHash, uploadResult.id)
return {
cacheHit: false,
cost: uploadResult.cost,
size: uploadResult.size,
transactionId: uploadResult.id,
updatedCache,
}
}
return {
cacheHit: false,
cost: uploadResult.cost,
size: uploadResult.size,
transactionId: uploadResult.id,
}
}
/** Default concurrency for parallel file uploads */
const DEFAULT_UPLOAD_CONCURRENCY = 10
interface FileUploadTask {
cached?: { transactionId: string }
fullPath: string
hash: string
relativePath: string
}
/**
* Upload a folder with per-file deduplication.
* Each file is checked against the cache individually, and only uncached files are uploaded.
* A manifest is then constructed and uploaded to create the folder structure.
*
* @param turbo - Upload client used for file and manifest uploads.
* @param folderPath - Folder to upload.
* @param options - Upload options for caching, concurrency, funding, and failure handling.
* @returns Folder upload result including manifest transaction ID and cache stats.
*/
export async function uploadFolder(
turbo: UploadClient,
folderPath: string,
options?: {
cache?: TransactionCache
concurrency?: number
/**
* Path, relative to the folder, whose transaction becomes the manifest's
* `fallback` — what a gateway serves for a path the manifest does not
* list. Defaults to `404.html` when present.
*/
fallbackFile?: string
fundingMode?: OnDemandFunding
throwOnFailure?: boolean
},
): Promise<FolderUploadResult> {
const concurrency = options?.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY
const useCache = options?.cache !== undefined
// Get all files in the folder
const relativePaths = getAllFiles(folderPath)
if (relativePaths.length === 0) {
throw new Error('Folder is empty, nothing to upload')
}
/*
* Validate before uploading anything: every check below this point happens
* after files have been paid for, and a mistyped fallback should cost
* nothing.
*/
if (options?.fallbackFile !== undefined && !relativePaths.includes(options.fallbackFile)) {
throw new Error(
`Fallback file not found in folder: ${options.fallbackFile}. ` +
`It must be a path relative to the deploy folder, e.g. "404.html".`,
)
}
// Prepare file tasks with hashes (if caching is enabled)
const tasks: FileUploadTask[] = await Promise.all(
relativePaths.map(async (relativePath) => {
const fullPath = path.join(folderPath, relativePath)
const hash = useCache ? await hashFile(fullPath) : ''
return { fullPath, hash, relativePath }
}),
)
// Check cache for each file
let cache = options?.cache ?? {}
let cacheHits = 0
for (const task of tasks) {
if (useCache && task.hash) {
const cached = getCachedTransaction(cache, task.hash)
if (cached) {
task.cached = { transactionId: cached.transactionId }
cache = touchCacheEntry(cache, task.hash)
cacheHits++
}
}
}
// If all files are cached, we still need to build and upload a new manifest
// (because the manifest itself has a unique transaction ID each time)
const uncachedTasks = tasks.filter((t) => !t.cached)
// Upload uncached files with concurrency control using p-limit
const limit = pLimit(concurrency)
const uploadResults = await Promise.all(
uncachedTasks.map((task) =>
limit(async () => {
const mimeType = mime.lookup(task.fullPath) || 'application/octet-stream'
const uploadResult = await turbo.uploadFile({
dataItemOpts: {
tags: [...provenanceTags(), { name: 'Content-Type', value: mimeType }],
},
file: task.fullPath,
...(options?.fundingMode && { fundingMode: options.fundingMode }),
})
if (!uploadResult?.id) {
if (options?.throwOnFailure) {
throw new Error(`Failed to upload file: ${task.relativePath}`)
}
return { hash: task.hash, task, transactionId: null }
}
return { hash: task.hash, task, transactionId: uploadResult.id }
}),
),
)
// Update cache with all successful uploads (done sequentially to avoid race conditions)
for (const result of uploadResults) {
if (useCache && result.hash && result.transactionId) {
cache = setCachedTransaction(cache, result.hash, result.transactionId)
}
}
// Check for any failed uploads
const failedUploads = uploadResults.filter((r) => r.transactionId === null)
if (failedUploads.length > 0 && options?.throwOnFailure) {
throw new Error(
`Failed to upload ${failedUploads.length} file(s): ${failedUploads.map((f) => f.task.relativePath).join(', ')}`,
)
}
// Build manifest paths from cached and newly uploaded files
const manifestPaths: Record<string, { id: string }> = {}
for (const task of tasks) {
let transactionId: string | null = null
if (task.cached) {
transactionId = task.cached.transactionId
} else {
const uploadResult = uploadResults.find((r) => r.task === task)
transactionId = uploadResult?.transactionId ?? null
}
if (transactionId) {
manifestPaths[task.relativePath] = { id: transactionId }
// Add directory index support: if file is dir/index.html, also add dir → same ID
if (task.relativePath.endsWith('/index.html')) {
const dirPath = task.relativePath.replace(/\/index\.html$/, '')
manifestPaths[dirPath] = { id: transactionId }
}
}
}
// Determine the index path (root index.html)
const indexPath = relativePaths.includes('index.html') ? 'index.html' : undefined
/*
* Determine the fallback — the transaction a gateway serves for any path the
* manifest does not list.
*
* Without one, an `arweave/paths` manifest 404s every route that is not a
* real file, which breaks deep links into any single-page app: the root
* loads and `/settings` does not. An explicit `fallbackFile` wins; otherwise
* `404.html` is used when the build emits one, matching the convention
* static hosts already use.
*
* Note the shape: `fallback` takes an `{ id }`, not the `{ path }` that
* `index` takes. The v0.2.0 spec differs between the two.
*/
const fallbackPath =
options?.fallbackFile ?? (relativePaths.includes('404.html') ? '404.html' : undefined)
const fallbackId = fallbackPath ? manifestPaths[fallbackPath]?.id : undefined
// Build the manifest
const manifest = {
manifest: 'arweave/paths',
version: '0.2.0',
...(indexPath && { index: { path: indexPath } }),
...(fallbackId && { fallback: { id: fallbackId } }),
paths: manifestPaths,
}
// Upload the manifest
const manifestBuffer = Buffer.from(JSON.stringify(manifest))
const manifestUploadResult = await turbo.uploadFile({
dataItemOpts: {
tags: [
...provenanceTags(),
{ name: 'Content-Type', value: 'application/x.arweave-manifest+json' },
{ name: 'Device', value: 'manifest@1.0' },
],
},
fileSizeFactory: () => manifestBuffer.length,
fileStreamFactory: () => Readable.from(manifestBuffer),
...(options?.fundingMode && { fundingMode: options.fundingMode }),
})
if (!manifestUploadResult?.id) {
throw new Error('Failed to upload manifest: upload result missing transaction ID')
}
return {
cacheHit: cacheHits === tasks.length,
cacheHits,
totalFiles: tasks.length,
transactionId: manifestUploadResult.id,
updatedCache: useCache ? cache : undefined,
uploaded: uncachedTasks.length - failedUploads.length,
}
}