Skip to content

Commit

Permalink
fix: extraction of @elgato/schemas (#28)
Browse files Browse the repository at this point in the history
* fix: extraction of @elgato/schemas

Previously, completion would occur when the contents of the HTTP stream had been fully read, and not when the contents had been extract. This changes updates the flow so that the file is first downloaded, integrity checked, and then installed. There is also a fallback in place to re-install the previous version should the update fail.

* refactor: switch file name to uuid

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Feb 22, 2024
1 parent 0f453c8 commit 17386e3
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 25 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Node.js
node_modules/

# Build output
# Build and temporary files
bin/
/.tmp/

# CLI
/.cli.cache
Expand Down
41 changes: 40 additions & 1 deletion src/common/path.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, lstatSync, readdirSync, readlinkSync, Stats } from "node:fs";
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, Stats } from "node:fs";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand Down Expand Up @@ -55,6 +55,45 @@ export function isSafeBaseName(value: string): boolean {
return !invalidCharacters.some((invalid) => value.includes(invalid));
}

/**
* Synchronously moves the {@link source} to the {@link dest} path.
* @param source Source path being moved.
* @param dest Destination where the {@link source} will be moved to.
* @param options Options that define the move.
*/
export function moveSync(source: string, dest: string, options?: MoveOptions): void {
if (!existsSync(source)) {
throw new Error("Source does not exist");
}

if (!lstatSync(source).isDirectory()) {
throw new Error("Source must be a directory");
}

if (existsSync(dest)) {
if (options?.overwrite) {
rmSync(dest, { recursive: true });
} else {
throw new Error("Destination already exists");
}
}

// Ensure the new directory exists, copy the contents, and clean-up.
mkdirSync(dest, { recursive: true });
cpSync(source, dest, { recursive: true });
rmSync(source, { recursive: true });
}

/**
* Defines how a path will be relocated.
*/
type MoveOptions = {
/**
* When the destination path already exists, it will be overwritten.
*/
overwrite?: boolean;
};

/**
* Resolves the specified {@link path} relatives to the entry point.
* @param path Path being resolved.
Expand Down
104 changes: 81 additions & 23 deletions src/package-manager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { Readable } from "node:stream";
import semver from "semver";
import tar from "tar";
import { dependencies, version } from "../package.json";
import { relative } from "./common/path";
import { moveSync, relative } from "./common/path";

/**
* Light-weight package manager that wraps npm, capable of updating locally-scoped installed packages.
Expand Down Expand Up @@ -51,27 +52,39 @@ class PackageManager {
* @param pkg Package to install.
*/
public async install(pkg: PackageMetadataVersion): Promise<void> {
const res = await fetch(pkg.dist.tarball);
await new Promise((resolve, reject) => {
if (res.body === null) {
reject(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}`);
return;
}

// Clean the installation directory.
const cwd = relative(`../node_modules/${pkg.name}`);
if (existsSync(cwd)) {
rmSync(cwd, { recursive: true });
// Download the package's tarball file to a temporary location.
const file = relative(`../.tmp/${crypto.randomUUID()}.tar.gz`);
mkdirSync(dirname(file), { recursive: true });

try {
await this.download(pkg, file);

// Determine the package paths.
const installationPath = relative(`../node_modules/${pkg.name}`);
const tempPath = relative("../.tmp/@elgato/schemas/");

try {
// Move the current installed package, and unpack the new package to node_modules.
moveSync(installationPath, tempPath, { overwrite: true });
mkdirSync(installationPath, { recursive: true });
await tar.extract({
file,
strip: 1,
cwd: installationPath
});
} catch (err) {
// When something goes wrong, fallback to the previous package.
moveSync(tempPath, installationPath, { overwrite: true });
throw err;
} finally {
// Cleanup the temporary cache.
if (existsSync(tempPath)) {
rmSync(tempPath, { recursive: true });
}
}

mkdirSync(cwd, { recursive: true });

// Decompress the contents fo the installation directory.
const stream = Readable.fromWeb(res.body);
stream.on("close", () => resolve(true));
stream.on("error", (err) => reject(err));
stream.pipe(tar.extract({ strip: 1, cwd }));
});
} finally {
rmSync(file);
}
}

/**
Expand Down Expand Up @@ -112,6 +125,51 @@ class PackageManager {
return (await res.json()) as PackageMetadata;
}

/**
* Downloads the contents of the specified {@link pkg} to the {@link dest} file.
* @param pkg Package to download.
* @param dest File where the packed (i.e. the tarball file) packaged will be downloaded to.
*/
private async download(pkg: PackageMetadataVersion, dest: string): Promise<void> {
if (existsSync(dest)) {
throw new Error(`File path already exists: ${dest}`);
}

const res = await fetch(pkg.dist.tarball);
if (res.body === null) {
throw new Error(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}`);
}

// Create a hash to validate the download.
const fileStream = createWriteStream(dest, { encoding: "utf-8" });
const body = Readable.fromWeb(res.body);
const hash = createHash("sha1");

return new Promise((resolve, reject) => {
fileStream.on("open", () => {
// Read the contents of the body into both the file stream, and the hash in parallel.
body
.on("data", (data) => {
hash.update(data);
fileStream.write(data);
})
.on("error", reject)
.on("close", () => {
fileStream.close(() => {
// Validate the shasum.
const shasum = hash.digest("hex");
if (shasum !== pkg.dist.shasum) {
rmSync(dest);
reject(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}: shasum mismatch`);
}

resolve();
});
});
});
});
}

/**
* Gets the latest version, from the package metadata, that satisfies the {@link range}.
* @param pkg Package metadata whose versions should be checked.
Expand Down

0 comments on commit 17386e3

Please sign in to comment.