Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/commands/apps/liveupdates/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isReadable,
getFilesInDirectoryAndSubdirectories,
isDirectory,
readFileFromDirectory,
} from '@/utils/file.js';
import { createHash } from '@/utils/hash.js';
import { generateManifestJson } from '@/utils/manifest.js';
Expand Down Expand Up @@ -442,7 +443,7 @@ const uploadFiles = async (options: {
fileIndex++;

consola.start(`Uploading file (${fileIndex}/${files.length})...`);
const buffer = await createBufferFromPath(file.path);
const buffer = await readFileFromDirectory(file.path);

await uploadFile({
appId,
Expand Down
65 changes: 65 additions & 0 deletions src/utils/file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import fs from 'fs';
import os from 'os';
import pathModule from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UserError } from './error.js';
import { readFileFromDirectory } from './file.js';

const { mockCreateBufferFromPath } = vi.hoisted(() => ({ mockCreateBufferFromPath: vi.fn() }));

vi.mock('./buffer.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./buffer.js')>();
mockCreateBufferFromPath.mockImplementation(actual.createBufferFromPath);
return { ...actual, createBufferFromPath: mockCreateBufferFromPath };
});

const createErrorWithCode = (code: string): Error => Object.assign(new Error(code), { code });

describe('readFileFromDirectory', () => {
let directory: string;

beforeEach(() => {
directory = fs.mkdtempSync(pathModule.join(os.tmpdir(), 'file-'));
});

afterEach(() => {
fs.rmSync(directory, { force: true, recursive: true });
});

it('should read the file', async () => {
const path = pathModule.join(directory, 'index.html');
fs.writeFileSync(path, '<html></html>');

const buffer = await readFileFromDirectory(path);

expect(buffer.toString()).toBe('<html></html>');
});

it('should throw a user error if the file no longer exists', async () => {
const path = pathModule.join(directory, 'missing.png');

const promise = readFileFromDirectory(path);

await expect(promise).rejects.toThrow(UserError);
await expect(promise).rejects.toThrow(`The file could not be read: ${path}. Make sure that no other process`);
});

it('should throw a user error if the file is not readable', async () => {
const path = pathModule.join(directory, 'index.html');
mockCreateBufferFromPath.mockRejectedValueOnce(createErrorWithCode('EACCES'));

const promise = readFileFromDirectory(path);

await expect(promise).rejects.toThrow(UserError);
await expect(promise).rejects.toThrow(
`The file could not be read: ${path}. Make sure that you have permission to read the file.`,
);
});

it('should rethrow errors that are not related to reading the file', async () => {
const error = createErrorWithCode('EISDIR');
mockCreateBufferFromPath.mockRejectedValueOnce(error);

await expect(readFileFromDirectory(pathModule.join(directory, 'assets'))).rejects.toBe(error);
});
});
30 changes: 30 additions & 0 deletions src/utils/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import fs from 'fs';
import mime from 'mime';
import pathModule from 'path';
import { createBufferFromPath } from './buffer.js';
import { getCodeFromUnknownError, UserError } from './error.js';

const concurrentModificationHint =
'Make sure that no other process (e.g. a build or file sync client) modifies the folder while the command is running.';
const permissionHint = 'Make sure that you have permission to read the file.';

const unreadableFileErrorHints: Record<string, string> = {
EACCES: permissionHint,
EBUSY: concurrentModificationHint,
ENOENT: concurrentModificationHint,
EPERM: permissionHint,
};

export const getFilesInDirectoryAndSubdirectories = async (
path: string,
Expand Down Expand Up @@ -40,6 +53,23 @@ export const getFilesInDirectoryAndSubdirectories = async (
return files;
};

/**
* Reads a file that was found by `getFilesInDirectoryAndSubdirectories`.
* Such files can vanish, get locked or become inaccessible in the meantime (e.g. by a running build or a file sync client).
*/
export const readFileFromDirectory = async (path: string): Promise<Buffer> => {
try {
return await createBufferFromPath(path);
} catch (error) {
const code = getCodeFromUnknownError(error);
const hint = code ? unreadableFileErrorHints[code] : undefined;
if (hint) {
throw new UserError(`The file could not be read: ${path}. ${hint}`);
}
throw error;
}
};

export const directoryContainsSymlinks = async (path: string): Promise<boolean> => {
const dirEntries = await fs.promises.readdir(path, { withFileTypes: true, recursive: true }).catch(() => []);
return dirEntries.some((dirEntry) => dirEntry.isSymbolicLink());
Expand Down
5 changes: 2 additions & 3 deletions src/utils/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { MANIFEST_JSON_FILE_NAME } from '@/config/index.js';
import { createBufferFromPath } from './buffer.js';
import { getFilesInDirectoryAndSubdirectories, writeFile } from './file.js';
import { getFilesInDirectoryAndSubdirectories, readFileFromDirectory, writeFile } from './file.js';
import { createHash } from './hash.js';

const ignoreFiles = ['.DS_Store', MANIFEST_JSON_FILE_NAME];
Expand All @@ -11,7 +10,7 @@ export const generateManifestJson = async (path: string) => {
const files = await getFilesInDirectoryAndSubdirectories(path);
// Iterate over each file
for (const [index, file] of files.entries()) {
const fileBuffer = await createBufferFromPath(file.path);
const fileBuffer = await readFileFromDirectory(file.path);
const checksum = await createHash(fileBuffer);
const sizeInBytes = fileBuffer.byteLength;
// Skip ignored files
Expand Down
Loading