Skip to content

Commit 58fd1dd

Browse files
committed
feat(core): support withTempFile to get local temp files from uploads
1 parent a11bda8 commit 58fd1dd

File tree

6 files changed

+173
-11
lines changed

6 files changed

+173
-11
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"node/no-unsupported-features/es-syntax": "off",
3434
"@typescript-eslint/explicit-function-return-type": "error",
3535
"@typescript-eslint/no-explicit-any": "off",
36+
"@typescript-eslint/no-unused-vars": "error",
3637
"curly": ["error", "all"],
3738
"max-len": ["error", { "code": 140, "ignoreUrls": true }],
3839
"no-undefined": "error",

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@commitlint/cli": "^7.5.2",
4040
"@commitlint/config-conventional": "^7.5.0",
4141
"@types/jest": "^24.0.11",
42+
"@types/tmp": "^0.1.0",
4243
"@typescript-eslint/eslint-plugin": "^1.5.0",
4344
"@typescript-eslint/parser": "^1.5.0",
4445
"commitizen": "^3.0.7",
@@ -92,6 +93,7 @@
9293
]
9394
},
9495
"dependencies": {
95-
"@carimus/node-disks": "^1.2.0"
96+
"@carimus/node-disks": "^1.8.0",
97+
"tmp": "^0.1.0"
9698
}
9799
}

src/lib/Uploads.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import * as fs from 'fs';
2+
import { promisify } from 'util';
13
import { DiskDriver, DiskManager } from '@carimus/node-disks';
24
import { MemoryRepository } from '../support';
35
import { Uploads } from './Uploads';
46
import { UploadMeta } from '../types';
57

8+
const readFileFromLocalFilesystem = promisify(fs.readFile);
9+
const deleteFromLocalFilesystem = promisify(fs.unlink);
10+
611
const disks = {
712
default: 'memory',
813
memory: {
@@ -46,7 +51,18 @@ const files: {
4651
weirdName: {
4752
uploadedAs: '.~my~cool~data~&^%$*(¶•ª•.csv',
4853
data: Buffer.from('a,b,c\nfoo,bar,baz\n1,2,3\n', 'utf8'),
49-
meta: { context: 'test', isFoo: false, isImage: true },
54+
meta: { context: 'test', isFoo: false, isImage: false },
55+
},
56+
longName: {
57+
uploadedAs:
58+
'~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.csv',
59+
data: Buffer.from('a,b,c\nfoo,bar,baz\n1,2,3\n', 'utf8'),
60+
meta: {
61+
context: 'test',
62+
isFoo: false,
63+
isImage: false,
64+
isSuperLong: true,
65+
},
5066
},
5167
};
5268

@@ -258,3 +274,44 @@ test('Uploads service can delete only the file', async () => {
258274
diskManager.getDisk(fileInfo.disk).read(fileInfo.path),
259275
).rejects.toBeTruthy();
260276
});
277+
278+
test('Uploads service can create temp files for local manipulation from uploads', async () => {
279+
const { diskManager, repository, uploads } = setup();
280+
281+
// Upload a file
282+
const upload = await uploads.upload(
283+
files.longName.data,
284+
files.longName.uploadedAs,
285+
files.longName.meta,
286+
);
287+
const fileInfo = await repository.getUploadedFileInfo(upload);
288+
const uploadedFileData = await diskManager
289+
.getDisk(fileInfo.disk)
290+
.read(fileInfo.path);
291+
292+
// Get the temp file for it and check to make sure their contents match
293+
const tempPath = await uploads.withTempFile(upload, async (path) => {
294+
const tempFileData = await readFileFromLocalFilesystem(path);
295+
expect(tempFileData.toString('base64')).toBe(
296+
uploadedFileData.toString('base64'),
297+
);
298+
});
299+
300+
// Ensure that once the callback is completed, the file doesn't exist since we didn't tell it not to cleanup
301+
expect(tempPath).toBeTruthy();
302+
await expect(readFileFromLocalFilesystem(tempPath)).rejects.toBeTruthy();
303+
304+
// Do the same stuff again but using the bypass cleanup approach to take cleanup into our own hands
305+
const persistentTempPath = await uploads.withTempFile(upload);
306+
expect(persistentTempPath).toBeTruthy();
307+
const persistentTempFileData = await readFileFromLocalFilesystem(
308+
persistentTempPath,
309+
);
310+
expect(persistentTempFileData.toString('base64')).toBe(
311+
uploadedFileData.toString('base64'),
312+
);
313+
// Note that we use `.resolves.toBeUndefined()` to verify the file is deleted (unlink resolves with void/undefined)
314+
expect(
315+
deleteFromLocalFilesystem(persistentTempPath),
316+
).resolves.toBeUndefined();
317+
});

src/lib/Uploads.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Readable } from 'stream';
2-
import { DiskManager } from '@carimus/node-disks';
2+
import * as fs from 'fs';
3+
import { DiskManager, pipeStreams } from '@carimus/node-disks';
34
import { InvalidConfigError, PathNotUniqueError } from '../errors';
45
import {
56
Upload,
@@ -8,7 +9,7 @@ import {
89
UploadRepository,
910
UploadsConfig,
1011
} from '../types';
11-
import { trimPath } from './utils';
12+
import { trimPath, withTempFile } from './utils';
1213
import { defaultSanitizeFilename, defaultGeneratePath } from './defaults';
1314

1415
/**
@@ -17,7 +18,6 @@ import { defaultSanitizeFilename, defaultGeneratePath } from './defaults';
1718
* TODO Do URL generation for publicly available disks.
1819
* TODO Support temporary URLs (e.g. presigned URLs for S3 buckets) for disks that support it
1920
* TODO Support transfer logic for transferring single uploads from one disk to another and in bulk.
20-
* TODO Support `getTemporaryFile` to copy an upload file to the local filesystem tmp directory for direct manipulation
2121
*/
2222
export class Uploads {
2323
private config: UploadsConfig;
@@ -248,4 +248,53 @@ export class Uploads {
248248
// Delete the file on the disk
249249
await disk.delete(file.path);
250250
}
251+
252+
/**
253+
* Download the file to the local disk as a temporary file for operations that require local data manipuation
254+
* and which can't handle Buffers, i.e. operations expected to be performed on large files where it's easier to
255+
* deal with the data in chunks off of the disk or something instead of keeping them in a Buffer in memory in their
256+
* entirety.
257+
*
258+
* This methods streams the data directly to the local filesystem so large files shouldn't cause any memory issues.
259+
*
260+
* If an `execute` callback is not provided, the cleanup step will be skipped and the path that this resolves to
261+
* will exist and can be manipulated directly. IMPORTANT: in such a scenario, the caller is responsible for
262+
* deleting the file when they're done with it.
263+
*
264+
* @param upload
265+
* @param execute
266+
*/
267+
public async withTempFile(
268+
upload: Upload,
269+
execute: ((path: string) => Promise<void> | void) | null = null,
270+
): Promise<string> {
271+
// Ask the repository for info on where and how the upload file is stored.
272+
const uploadedFile = await this.repository.getUploadedFileInfo(upload);
273+
// Resolve the disk for the file.
274+
const disk = this.disks.getDisk(uploadedFile.disk);
275+
// Generate a descriptive postfix for the temp file that isn't too long.
276+
const postfix = `-${uploadedFile.uploadedAs}`.slice(-50);
277+
// Create a temp file, write the upload's file data to it, and pass its path to
278+
return withTempFile(
279+
async (path: string) => {
280+
// Create a write stream to the temp file that will auto close once the stream is fully piped.
281+
const tempFileWriteStream = fs.createWriteStream(path, {
282+
autoClose: true,
283+
});
284+
// Create a read stream for the file on the disk.
285+
const diskFileReadStream = await disk.createReadStream(
286+
uploadedFile.path,
287+
);
288+
// Pipe the disk read stream to the temp file write stream.
289+
await pipeStreams(diskFileReadStream, tempFileWriteStream);
290+
// Run the caller callback if it was provided.
291+
if (execute) {
292+
await execute(path);
293+
}
294+
},
295+
// Skip clean up if no execute callback is provided.
296+
!execute,
297+
{ postfix },
298+
);
299+
}
251300
}

src/lib/utils.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Readable, Stream, Writable } from 'stream';
1+
import tmp = require('tmp');
22

33
/**
44
* Remove all whitespace and slashes from the beginning and end of a string.
@@ -8,3 +8,44 @@ import { Readable, Stream, Writable } from 'stream';
88
export function trimPath(path: string): string {
99
return `${path}`.replace(/(^(\s|\/)+|(\s|\/)+$)/g, '');
1010
}
11+
12+
/**
13+
* Create a temp file and do something with it.
14+
*
15+
* @param execute An optionally async function that will receive the temp file's name (path)
16+
* @param skipCleanup If true, don't delete the file until process end.
17+
* @param extraOptions Additional options to pass into `tmp.file`
18+
* @return The temporary's file path which won't exist after this resolves unless `skipCleanup` was `true`
19+
*/
20+
export async function withTempFile(
21+
execute: (name: string) => Promise<void> | void,
22+
skipCleanup: boolean = false,
23+
extraOptions: import('tmp').FileOptions = {},
24+
): Promise<string> {
25+
// Receive the temp file's name (path) and cleanup function from `tmp`, throwing if it rejects.
26+
const {
27+
name,
28+
cleanupCallback,
29+
}: { name: string; cleanupCallback: () => void } = await new Promise(
30+
(resolve, reject) => {
31+
tmp.file(
32+
{ discardDescriptor: true, ...extraOptions },
33+
(err, name, fd, cleanupCallback) => {
34+
if (err) {
35+
reject(err);
36+
} else {
37+
resolve({ name, cleanupCallback });
38+
}
39+
},
40+
);
41+
},
42+
);
43+
// Run the execute callback with the name (path)
44+
await execute(name);
45+
// Don't delete the file if requested.
46+
if (!skipCleanup) {
47+
await cleanupCallback();
48+
}
49+
// Return the temporary file's name (path)
50+
return name;
51+
}

yarn.lock

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@
138138
lodash "^4.17.11"
139139
to-fast-properties "^2.0.0"
140140

141-
"@carimus/node-disks@^1.2.0":
142-
version "1.7.0"
143-
resolved "https://registry.yarnpkg.com/@carimus/node-disks/-/node-disks-1.7.0.tgz#fcf9c1f9ca275935fc84a4286766266bec00a6f7"
144-
integrity sha512-S7eBw5G7ykToLMO3FINDSrI/wbJniau/AGvkWgNWiq0c5nz+QaSqAQ9YB+ZxTpRoeYtCBypNXC7OjREd4uQ4TQ==
141+
"@carimus/node-disks@^1.8.0":
142+
version "1.8.0"
143+
resolved "https://registry.yarnpkg.com/@carimus/node-disks/-/node-disks-1.8.0.tgz#d5d12300dfaf244889570f5309e354b8df18ef3b"
144+
integrity sha512-zdi7euqzn76Z2hyMBN5hUsr9beSDRB8fCktRz4xuUTXNAqmL15kCKruSV3WHB7tz4nUDn+Hr2yCYM3U+S3jgyw==
145145
dependencies:
146146
aws-sdk "^2.431.0"
147147
fs-extra "^7.0.1"
@@ -644,6 +644,11 @@
644644
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
645645
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
646646

647+
"@types/tmp@^0.1.0":
648+
version "0.1.0"
649+
resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.1.0.tgz#19cf73a7bcf641965485119726397a096f0049bd"
650+
integrity sha512-6IwZ9HzWbCq6XoQWhxLpDjuADodH/MKXRUIDFudvgjcVdjFknvmR+DNsoUeer4XPrEnrZs04Jj+kfV9pFsrhmA==
651+
647652
"@types/yargs@^12.0.2", "@types/yargs@^12.0.9":
648653
version "12.0.10"
649654
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.10.tgz#17a8ec65cd8e88f51b418ceb271af18d3137df67"
@@ -6601,7 +6606,7 @@ right-pad@^1.0.1:
66016606
resolved "https://registry.yarnpkg.com/right-pad/-/right-pad-1.0.1.tgz#8ca08c2cbb5b55e74dafa96bf7fd1a27d568c8d0"
66026607
integrity sha1-jKCMLLtbVedNr6lr9/0aJ9VoyNA=
66036608

6604-
rimraf@2, rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2:
6609+
rimraf@2, rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@~2.6.2:
66056610
version "2.6.3"
66066611
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
66076612
integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
@@ -7387,6 +7392,13 @@ tmp@^0.0.33:
73877392
dependencies:
73887393
os-tmpdir "~1.0.2"
73897394

7395+
tmp@^0.1.0:
7396+
version "0.1.0"
7397+
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877"
7398+
integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==
7399+
dependencies:
7400+
rimraf "^2.6.3"
7401+
73907402
tmpl@1.0.x:
73917403
version "1.0.4"
73927404
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"

0 commit comments

Comments
 (0)