From 60238e81fe02432661b617aee180aa009fc8d2d5 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Mon, 15 Apr 2024 00:51:47 +0100 Subject: [PATCH] Add writing utility --- .../src/commands/generate-output/runner.ts | 9 +-- .../src/commands/generate-schema/runner.ts | 11 ++-- .../cli-utils/src/commands/shared/index.ts | 2 + .../cli-utils/src/commands/shared/utils.ts | 57 +++++++++++++++++++ 4 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 packages/cli-utils/src/commands/shared/index.ts create mode 100644 packages/cli-utils/src/commands/shared/utils.ts diff --git a/packages/cli-utils/src/commands/generate-output/runner.ts b/packages/cli-utils/src/commands/generate-output/runner.ts index dbc75f4d..03041bbd 100644 --- a/packages/cli-utils/src/commands/generate-output/runner.ts +++ b/packages/cli-utils/src/commands/generate-output/runner.ts @@ -12,6 +12,8 @@ import { } from '@gql.tada/internal'; import type { TTY } from '../../term'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; import * as logger from './logger'; interface Options { @@ -57,10 +59,9 @@ export async function* run(tty: TTY, opts: Options) { throw logger.externalError('Could not generate introspection output', error); } - let destination: string; + let destination: WriteTarget; if (!opts.output && tty.pipeTo) { - tty.pipeTo.write(contents); - return; + destination = tty.pipeTo; } else if (opts.output) { destination = path.resolve(process.cwd(), opts.output); } else if (pluginConfig.tadaOutputLocation) { @@ -80,7 +81,7 @@ export async function* run(tty: TTY, opts: Options) { } try { - await fs.writeFile(destination, contents); + await writeOutput(destination, contents); } catch (error) { throw logger.externalError('Something went wrong while writing the introspection file', error); } diff --git a/packages/cli-utils/src/commands/generate-schema/runner.ts b/packages/cli-utils/src/commands/generate-schema/runner.ts index e6568d56..ab579772 100644 --- a/packages/cli-utils/src/commands/generate-schema/runner.ts +++ b/packages/cli-utils/src/commands/generate-schema/runner.ts @@ -6,8 +6,8 @@ import type { GraphQLSPConfig, LoadConfigResult } from '@gql.tada/internal'; import { load, loadConfig, parseConfig } from '@gql.tada/internal'; import type { TTY } from '../../term'; -import { getGraphQLSPConfig } from '../../lsp'; -import { getTsConfig } from '../../tsconfig'; +import type { WriteTarget } from '../shared'; +import { writeOutput } from '../shared'; import * as logger from './logger'; interface Options { @@ -32,10 +32,9 @@ export async function* run(tty: TTY, opts: Options) { throw logger.errorMessage('Failed to load schema.'); } - let destination: string; + let destination: WriteTarget; if (!opts.output && tty.pipeTo) { - tty.pipeTo.write(printSchema(schema)); - return; + destination = tty.pipeTo; } else if (opts.output) { destination = path.resolve(process.cwd(), opts.output); } else { @@ -70,7 +69,7 @@ export async function* run(tty: TTY, opts: Options) { } try { - await fs.writeFile(destination, printSchema(schema)); + await writeOutput(destination, printSchema(schema)); } catch (error) { throw logger.externalError('Something went wrong while writing the introspection file', error); } diff --git a/packages/cli-utils/src/commands/shared/index.ts b/packages/cli-utils/src/commands/shared/index.ts new file mode 100644 index 00000000..8ff45f49 --- /dev/null +++ b/packages/cli-utils/src/commands/shared/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './utils'; diff --git a/packages/cli-utils/src/commands/shared/utils.ts b/packages/cli-utils/src/commands/shared/utils.ts new file mode 100644 index 00000000..5e847f59 --- /dev/null +++ b/packages/cli-utils/src/commands/shared/utils.ts @@ -0,0 +1,57 @@ +import type { WriteStream } from 'node:tty'; +import type { PathLike } from 'node:fs'; +import * as fs from 'node:fs/promises'; + +/** Checks whether a file exists on disk */ +export const fileExists = (file: PathLike): Promise => + fs + .stat(file) + .then((stat) => stat.isFile()) + .catch(() => false); + +const touchFile = async (file: PathLike): Promise => { + try { + const now = new Date(); + await fs.utimes(file, now, now); + } catch (_error) {} +}; + +export type WriteTarget = PathLike | WriteStream; + +/** Writes a file to a swapfile then moves it into place to prevent excess change events. */ +export const writeOutput = async (target: WriteTarget, contents: string): Promise => { + if (target && typeof target === 'object' && 'writable' in target) { + // If we get a WritableStream (e.g. stdout), we write to that + // but we listen for errors and wait for it to flush fully + return await new Promise((resolve, reject) => { + target.write(contents, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } else if (!(await fileExists(target))) { + // If the file doesn't exist, we can write directly, and not + // try-catch so the error falls through + await fs.writeFile(target, contents); + } else { + // If the file exists, we write to a swap-file, then rename (i.e. move) + // the file into place. No try-catch around `writeFile` for proper + // directory/permission errors + const tempTarget = target + '.tmp'; + await fs.writeFile(tempTarget, contents); + try { + await fs.rename(tempTarget, target); + } catch (error) { + await fs.unlink(tempTarget); + throw error; + } finally { + // When we move the file into place, we also update its access and + // modification time manually, in case the rename doesn't trigger + // a change event + await touchFile(target); + } + } +};