Skip to content

Commit

Permalink
Add TempFileStorage
Browse files Browse the repository at this point in the history
  • Loading branch information
skycoop committed Sep 20, 2024
1 parent 7a7aa82 commit 745b2cd
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 47 deletions.
36 changes: 1 addition & 35 deletions packages/file-storage/src/lib/local-file-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'node:path';
import { openFile, writeFile } from '@mjackson/lazy-file/fs';

import { FileStorage } from './file-storage.js';
import { FileMetadata, isNoEntityError, storeFile } from './utils.js';

/**
* A `FileStorage` that is backed by the local filesystem.
Expand Down Expand Up @@ -89,37 +90,6 @@ export class LocalFileStorage implements FileStorage {
}
}

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;
}

function randomFilename(): string {
return `${new Date().getTime().toString(36)}.${Math.random().toString(36).slice(2, 6)}`;
}

interface FileMetadata {
file: string;
name: string;
type: string;
mtime: number;
}

class FileMetadataIndex {
#path: string;

Expand Down Expand Up @@ -163,7 +133,3 @@ class FileMetadataIndex {
await this.#save({ ...info, [key]: undefined });
}
}

function isNoEntityError(obj: unknown): obj is NodeJS.ErrnoException & { code: 'ENOENT' } {
return obj instanceof Error && 'code' in obj && (obj as NodeJS.ErrnoException).code === 'ENOENT';
}
57 changes: 57 additions & 0 deletions packages/file-storage/src/lib/temp-file-storage.spec.ts
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));
});
});
103 changes: 103 additions & 0 deletions packages/file-storage/src/lib/temp-file-storage.ts
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();
}
}
39 changes: 39 additions & 0 deletions packages/file-storage/src/lib/utils.ts
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';
}
1 change: 1 addition & 0 deletions packages/file-storage/src/temp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TempFileStorage } from './lib/temp-file-storage.js';
33 changes: 21 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 745b2cd

Please sign in to comment.