-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
222 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import assert from 'node:assert/strict'; | ||
import fs from 'node:fs'; | ||
import { describe, it } from 'node:test'; | ||
import { TempFileStorage } from './temp-file-storage.js'; | ||
|
||
describe('TempFileStorage', () => { | ||
it('stores and retrieves files', async () => { | ||
await using storage = new TempFileStorage('test-'); | ||
let lastModified = Date.now(); | ||
let file = new File(['Hello, world!'], 'hello.txt', { | ||
type: 'text/plain', | ||
lastModified, | ||
}); | ||
|
||
await storage.set('hello', file); | ||
|
||
assert.ok(storage.has('hello')); | ||
|
||
let retrieved = storage.get('hello'); | ||
|
||
assert.ok(retrieved); | ||
assert.equal(retrieved.name, 'hello.txt'); | ||
assert.equal(retrieved.type, 'text/plain'); | ||
assert.equal(retrieved.lastModified, lastModified); | ||
assert.equal(retrieved.size, 13); | ||
|
||
let text = await retrieved.text(); | ||
|
||
assert.equal(text, 'Hello, world!'); | ||
|
||
await storage.remove('hello'); | ||
|
||
assert.ok(!storage.has('hello')); | ||
assert.equal(storage.get('hello'), null); | ||
}); | ||
|
||
it('only creates dir after set is called and removes the dir on disposal', async () => { | ||
let dir: string; | ||
{ | ||
await using storage = new TempFileStorage('test-'); | ||
assert.equal(storage.dirname, undefined); | ||
|
||
await storage.set( | ||
'hello', | ||
new File(['Hello, world!'], 'hello.txt', { | ||
type: 'text/plain', | ||
lastModified: Date.now(), | ||
}), | ||
); | ||
|
||
assert.notEqual(storage.dirname, undefined); | ||
assert.doesNotThrow(() => fs.accessSync(storage.dirname!)); | ||
dir = storage.dirname!; | ||
} | ||
assert.throws(() => fs.accessSync(dir)); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import fsp from 'node:fs/promises'; | ||
import os from 'node:os'; | ||
import path from 'node:path'; | ||
|
||
import { FileStorage } from './file-storage.js'; | ||
import { FileMetadata, isNoEntityError, storeFile } from './utils.js'; | ||
import { openFile } from '@mjackson/lazy-file/fs'; | ||
|
||
type MkdtempOptions = Parameters<typeof fsp.mkdtemp>[1]; | ||
|
||
/** | ||
* A lazy `FileStorage` that is backed by a temporary directory on the local | ||
* filesystem. The temporary directory is only created if `set` is called. | ||
* | ||
* This class implements AsyncDisposable and should be used with `await using`, | ||
* but the `destroy` method is exposed for manual cleanup. | ||
* | ||
* Note: Keys have no correlation to file names on disk, so they may be any string including | ||
* characters that are not valid in file names. Additionally, individual `File` names have no | ||
* correlation to names of files on disk, so multiple files with the same name may be stored in the | ||
* same storage object. | ||
*/ | ||
export class TempFileStorage implements FileStorage, AsyncDisposable { | ||
#mkdir: () => Promise<string>; | ||
#dirname: string | undefined; | ||
#metadata = new Map<string, FileMetadata>(); | ||
|
||
constructor(prefix: string, options?: MkdtempOptions) { | ||
this.#mkdir = () => fsp.mkdtemp(path.join(os.tmpdir(), prefix), options); | ||
} | ||
|
||
get dirname() { | ||
return this.#dirname; | ||
} | ||
|
||
has(key: string): boolean { | ||
return this.#metadata.has(key); | ||
} | ||
|
||
async set(key: string, file: File): Promise<void> { | ||
if (!this.#dirname) { | ||
this.#dirname = await this.#mkdir(); | ||
} | ||
|
||
// Remove any existing file with the same key. | ||
await this.remove(key); | ||
|
||
let storedFile = await storeFile(this.#dirname, file); | ||
|
||
this.#metadata.set(key, { | ||
file: storedFile, | ||
name: file.name, | ||
type: file.type, | ||
mtime: file.lastModified, | ||
}); | ||
} | ||
|
||
get(key: string): File | null { | ||
let metadata = this.#metadata.get(key); | ||
if (metadata == null || !this.#dirname) return null; | ||
|
||
let filename = path.join(this.#dirname, metadata.file); | ||
|
||
return openFile(filename, { | ||
name: metadata.name, | ||
type: metadata.type, | ||
lastModified: metadata.mtime, | ||
}); | ||
} | ||
|
||
async remove(key: string): Promise<void> { | ||
let metadata = this.#metadata.get(key); | ||
if (metadata == null || !this.#dirname) return; | ||
|
||
let filename = path.join(this.#dirname, metadata.file); | ||
|
||
try { | ||
await fsp.unlink(filename); | ||
} catch (error) { | ||
if (!isNoEntityError(error)) { | ||
throw error; | ||
} | ||
} | ||
|
||
this.#metadata.delete(key); | ||
} | ||
|
||
/** | ||
* Deletes the temporary directory and resets the metadata. Prefer using this | ||
* class as a disposable with `await using` instead of calling this method. | ||
*/ | ||
async destroy(): Promise<void> { | ||
if (this.#dirname) { | ||
await fsp.rm(this.#dirname, { recursive: true, force: true }); | ||
this.#dirname = undefined; | ||
this.#metadata = new Map(); | ||
} | ||
} | ||
|
||
async [Symbol.asyncDispose](): Promise<void> { | ||
await this.destroy(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import fsp from 'node:fs/promises'; | ||
import path from 'node:path'; | ||
|
||
import { writeFile } from '@mjackson/lazy-file/fs'; | ||
|
||
export interface FileMetadata { | ||
file: string; | ||
name: string; | ||
type: string; | ||
mtime: number; | ||
} | ||
|
||
function randomFilename(): string { | ||
return `${new Date().getTime().toString(36)}.${Math.random().toString(36).slice(2, 6)}`; | ||
} | ||
|
||
export async function storeFile(dirname: string, file: File): Promise<string> { | ||
let filename = randomFilename(); | ||
|
||
let handle: fsp.FileHandle; | ||
try { | ||
handle = await fsp.open(path.join(dirname, filename), 'w'); | ||
} catch (error) { | ||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') { | ||
// Try again with a different filename | ||
return storeFile(dirname, file); | ||
} else { | ||
throw error; | ||
} | ||
} | ||
|
||
await writeFile(handle, file); | ||
|
||
return filename; | ||
} | ||
|
||
export function isNoEntityError(obj: unknown): obj is NodeJS.ErrnoException & { code: 'ENOENT' } { | ||
return obj instanceof Error && 'code' in obj && (obj as NodeJS.ErrnoException).code === 'ENOENT'; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { TempFileStorage } from './lib/temp-file-storage.js'; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.