Skip to content

Commit dad55a3

Browse files
feat: add support for conditional writes to BlobsServer (#306)
1 parent de3295f commit dad55a3

File tree

2 files changed

+104
-9
lines changed

2 files changed

+104
-9
lines changed

packages/blobs/src/server.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,55 @@ test('Accepts deploy-scoped stores with the region defined in the context', asyn
463463
await server.stop()
464464
await fs.rm(directory.path, { force: true, recursive: true })
465465
})
466+
467+
test('Handles conditional writes', async () => {
468+
const directory = await tmp.dir()
469+
const server = new BlobsServer({
470+
directory: directory.path,
471+
token,
472+
})
473+
const { port } = await server.start()
474+
const deployID = '655f77a1b48f470008e5879a'
475+
const key = 'conditional-key'
476+
const value1 = 'value 1'
477+
const value2 = 'value 2'
478+
const value3 = 'value 3'
479+
480+
const context = {
481+
deployID,
482+
edgeURL: `http://localhost:${port}`,
483+
primaryRegion: 'us-east-1',
484+
siteID,
485+
token,
486+
}
487+
488+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
489+
490+
const store1 = getStore({
491+
edgeURL: `http://localhost:${port}`,
492+
name: 'my-store',
493+
token,
494+
siteID,
495+
})
496+
497+
const res1 = await store1.set(key, value1, { onlyIfNew: true })
498+
499+
expect(res1.modified).toBe(true)
500+
expect(await store1.get(key)).toBe(value1)
501+
502+
const res2 = await store1.set(key, value2, { onlyIfNew: true })
503+
504+
expect(res2.modified).toBe(false)
505+
expect(await store1.get(key)).toBe(value1)
506+
507+
const res3 = await store1.set(key, value3, { onlyIfMatch: `"wrong-etag"` })
508+
expect(res3.modified).toBe(false)
509+
expect(await store1.get(key)).toBe(value1)
510+
511+
const res4 = await store1.set(key, value3, { onlyIfMatch: res1.etag })
512+
expect(res4.modified).toBe(true)
513+
expect(await store1.get(key)).toBe(value3)
514+
515+
await server.stop()
516+
await fs.rm(directory.path, { force: true, recursive: true })
517+
})

packages/blobs/src/server.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export class BlobsServer {
189189
return new Response(null, { status: 404 })
190190
}
191191

192+
this.logDebug('Error when reading data:', error)
193+
192194
return new Response(null, { status: 500 })
193195
}
194196
}
@@ -291,16 +293,36 @@ export class BlobsServer {
291293
return new Response(null, { status: 400 })
292294
}
293295

294-
const metadataHeader = req.headers.get(METADATA_HEADER_INTERNAL)
295-
const metadata = decodeMetadata(metadataHeader)
296+
// Check conditional write headers.
297+
const ifMatch = req.headers.get('if-match')
298+
const ifNoneMatch = req.headers.get('if-none-match')
296299

297300
try {
301+
let fileExists = false
302+
try {
303+
await fs.access(dataPath)
304+
305+
fileExists = true
306+
} catch {}
307+
308+
const currentEtag = fileExists ? await BlobsServer.generateETag(dataPath) : undefined
309+
310+
if (ifNoneMatch === '*' && fileExists) {
311+
return new Response(null, { status: 412 })
312+
}
313+
314+
if (ifMatch && (!fileExists || ifMatch !== currentEtag)) {
315+
return new Response(null, { status: 412 })
316+
}
317+
318+
const metadataHeader = req.headers.get(METADATA_HEADER_INTERNAL)
319+
const metadata = decodeMetadata(metadataHeader)
320+
298321
// We can't have multiple requests writing to the same file, which could
299322
// happen if multiple clients try to write to the same key at the same
300323
// time. To prevent this, we write to a temporary file first and then
301324
// atomically move it to its final destination.
302325
const tempPath = join(tmpdir(), Math.random().toString())
303-
304326
const body = await req.arrayBuffer()
305327
await fs.writeFile(tempPath, Buffer.from(body))
306328
await fs.mkdir(dirname(dataPath), { recursive: true })
@@ -311,10 +333,18 @@ export class BlobsServer {
311333
await fs.writeFile(metadataPath, JSON.stringify(metadata))
312334
}
313335

314-
return new Response(null, { status: 200 })
315-
} catch (error) {
316-
this.logDebug('Error when writing data:', error)
336+
const newEtag = await BlobsServer.generateETag(dataPath)
317337

338+
return new Response(null, {
339+
status: 200,
340+
headers: {
341+
etag: newEtag,
342+
},
343+
})
344+
} catch (error) {
345+
if (isNodeError(error)) {
346+
this.logDebug('Error when writing data:', error)
347+
}
318348
return new Response(null, { status: 500 })
319349
}
320350
}
@@ -356,6 +386,20 @@ export class BlobsServer {
356386
return { dataPath, key: key.join('/'), metadataPath, rootPath: storePath }
357387
}
358388

389+
/**
390+
* Helper method to generate an ETag for a file based on its path and last modified time.
391+
*/
392+
private static async generateETag(filePath: string): Promise<string> {
393+
try {
394+
const stats = await fs.stat(filePath)
395+
const hash = createHmac('sha256', stats.mtime.toISOString()).update(filePath).digest('hex')
396+
397+
return `"${hash}"`
398+
} catch {
399+
return ''
400+
}
401+
}
402+
359403
private async handleRequest(req: Request): Promise<Response> {
360404
if (!req.url || !this.validateAccess(req)) {
361405
return new Response(null, { status: 403 })
@@ -502,9 +546,8 @@ export class BlobsServer {
502546

503547
// If the entry is a file, add it to the `blobs` bucket.
504548
if (!stat.isDirectory()) {
505-
// We don't support conditional requests in the local server, so we
506-
// generate a random ETag for each entry.
507-
const etag = Math.random().toString().slice(2)
549+
// Generate a deterministic ETag based on file path and last modified time.
550+
const etag = await this.generateETag(entryPath)
508551

509552
result.blobs?.push({
510553
etag,

0 commit comments

Comments
 (0)