From 0a0bff3557b0cf46394ace864029ba7fa3e22f73 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Fri, 15 Aug 2025 18:34:11 +0200 Subject: [PATCH 1/2] feat(apollo-react-relay-duct-tape-compiler): allow graphql files export --- .../src/cli.ts | 18 ++- ...elayCompilerLanguagePluginWithArtifacts.ts | 124 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts diff --git a/packages/apollo-react-relay-duct-tape-compiler/src/cli.ts b/packages/apollo-react-relay-duct-tape-compiler/src/cli.ts index 9c879f2fb..d2dd95e00 100644 --- a/packages/apollo-react-relay-duct-tape-compiler/src/cli.ts +++ b/packages/apollo-react-relay-duct-tape-compiler/src/cli.ts @@ -13,6 +13,7 @@ import { enableNodeWatchQueryTransform } from "./compilerTransforms/enableNodeWa import { annotateFragmentReferenceTransform } from "./compilerTransforms/annotateFragmentReferenceTransform"; import { emitApolloClientConnectionTransform } from "./compilerTransforms/emitApolloClientConnectionTransform"; import { retainConnectionDirectiveTransform } from "./compilerTransforms/retainConnectionDirectiveTransform"; +import { withArtifacts } from "./relayCompilerLanguagePluginWithArtifacts"; function wrapTransform( transformName: string, @@ -122,6 +123,11 @@ async function main() { } }, }, + graphQLFilesOutputDir: { + demandOption: false, + default: "", + type: "string", + }, }) .help().argv; @@ -137,10 +143,14 @@ async function main() { } const ductTapeCompilerLanguagePlugin = await pluginFactory(argv); + const ductTapeCompilerLanguagePluginWithArtifacts = withArtifacts( + ductTapeCompilerLanguagePlugin, + argv, + ); - return relayCompiler({ + const result = await relayCompiler({ ...argv, - language: ductTapeCompilerLanguagePlugin, + language: ductTapeCompilerLanguagePluginWithArtifacts, extensions: ["ts", "tsx"], // FIXME: Why is this not taken from the language plugin? include: argv.include || ["**"], exclude: [ @@ -154,6 +164,10 @@ async function main() { noFutureProofEnums: true, customScalars: {}, }); + const now = performance.now(); + await ductTapeCompilerLanguagePluginWithArtifacts.flush(); + console.debug("GraphQL files flushing time (ms): ", performance.now() - now); + return result; } main().catch((error) => { diff --git a/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts b/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts new file mode 100644 index 000000000..764ef2de3 --- /dev/null +++ b/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts @@ -0,0 +1,124 @@ +import { + FormatModule, + PluginInitializer, +} from "relay-compiler/lib/language/RelayLanguagePluginInterface"; +import { writeFile } from "fs/promises"; +import { dirname, isAbsolute, join } from "path"; +import invariant from "invariant"; + +type Options = { + graphQLFilesOutputDir: string; +}; + +type PluginWithArtifactsInitializer = PluginInitializer & { + flush: () => Promise; +}; + +type EmitArtifactState = { + options: Options; + buffer: { file: string; data: string }[]; + activeFlushes: Set>; + totalTime: number; +}; + +const BATCH_SIZE = 8; +const WRITE_RETRY_TIMEOUT_MS = 1000; + +/** + * Allows extracting additional artefacts as a side effect of language plugin work + */ +export function withArtifacts( + plugin: PluginInitializer, + options: Options, +): PluginWithArtifactsInitializer { + const state: EmitArtifactState = { + options, + buffer: [], + activeFlushes: new Set(), + totalTime: 0.0, + }; + const initializer: PluginWithArtifactsInitializer = () => { + const pluginWithoutArtifacts = plugin(); + return { + ...pluginWithoutArtifacts, + formatModule: (entry) => { + processEntry(state, entry); + return pluginWithoutArtifacts.formatModule(entry); + }, + }; + }; + initializer.flush = async () => { + flushBuffer(state); + await Promise.all(state.activeFlushes); + // state.activeFlushes.clear(); + invariant(state.buffer.length === 0, "Buffer must be empty"); + invariant(state.activeFlushes.size === 0, "Active flushes must be empty"); + }; + return initializer; +} + +type FormatModuleOptions = Parameters[0]; + +function processEntry( + state: EmitArtifactState, + { moduleName, kind, docText, definition }: FormatModuleOptions, +) { + const { options, buffer } = state; + if (options.graphQLFilesOutputDir && kind === "Request" && docText) { + invariant(definition.loc.kind === "Source", "Expecting source operation"); + const sourcePath = definition.loc.source.name; + invariant(sourcePath, "Expecting source file path"); + + buffer.push({ + file: resolvePath(sourcePath, moduleName, state.options), + data: docText, + }); + } + if (buffer.length >= BATCH_SIZE) { + flushBuffer(state); + } +} + +function resolvePath( + sourcePath: string, + moduleName: string, + options: Options, +): string { + const dir = options.graphQLFilesOutputDir; + if (isAbsolute(dir)) { + // Assuming all moduleNames are unique (Relay enforces this) + return join(dir, moduleName); + } + if (dir.startsWith(".")) { + // Rely on CWD + return join(dir, moduleName); + } + if (dir === "__generated__") { + throw new Error( + `Option --graphQLFilesOutputDir cannot be equal to __generated__. This directory is expected to contain runtime artifacts only`, + ); + } + return join(dirname(sourcePath), dir, moduleName + ".extracted"); +} + +async function flushBuffer(state: EmitArtifactState) { + const start = performance.now(); + const promises = state.buffer.map((e) => writeWithRetry(e.file, e.data)); + state.buffer.length = 0; + const promise = Promise.all(promises); + state.activeFlushes.add(promise); + await promise; + state.totalTime += performance.now() - start; + state.activeFlushes.delete(promise); +} + +async function writeWithRetry(file: string, data: string) { + try { + await writeFile(file, data); + } catch (e) { + await new Promise((resolve) => { + setTimeout(resolve, WRITE_RETRY_TIMEOUT_MS); + }); + await writeFile(file, data); + } +} From c51803e5056095381514c6da6e626607e6a05b96 Mon Sep 17 00:00:00 2001 From: vrazuvaev Date: Mon, 18 Aug 2025 14:02:15 +0200 Subject: [PATCH 2/2] ensure dirs --- ...elayCompilerLanguagePluginWithArtifacts.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts b/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts index 764ef2de3..9ebe7f4e2 100644 --- a/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts +++ b/packages/apollo-react-relay-duct-tape-compiler/src/relayCompilerLanguagePluginWithArtifacts.ts @@ -3,6 +3,7 @@ import { PluginInitializer, } from "relay-compiler/lib/language/RelayLanguagePluginInterface"; import { writeFile } from "fs/promises"; +import { existsSync, mkdirSync } from "fs"; import { dirname, isAbsolute, join } from "path"; import invariant from "invariant"; @@ -18,10 +19,10 @@ type EmitArtifactState = { options: Options; buffer: { file: string; data: string }[]; activeFlushes: Set>; - totalTime: number; + ensuredDirs: Set; }; -const BATCH_SIZE = 8; +const READ_BATCH_SIZE = 8; // How many operations to read before flushing writes const WRITE_RETRY_TIMEOUT_MS = 1000; /** @@ -31,11 +32,16 @@ export function withArtifacts( plugin: PluginInitializer, options: Options, ): PluginWithArtifactsInitializer { + if (!options.graphQLFilesOutputDir) { + const init = () => plugin(); + init.flush = async () => {}; + return init; + } const state: EmitArtifactState = { options, buffer: [], activeFlushes: new Set(), - totalTime: 0.0, + ensuredDirs: new Set(), }; const initializer: PluginWithArtifactsInitializer = () => { const pluginWithoutArtifacts = plugin(); @@ -45,6 +51,7 @@ export function withArtifacts( processEntry(state, entry); return pluginWithoutArtifacts.formatModule(entry); }, + isGeneratedFile: () => true, }; }; initializer.flush = async () => { @@ -71,10 +78,12 @@ function processEntry( buffer.push({ file: resolvePath(sourcePath, moduleName, state.options), - data: docText, + data: + `# Extracted by @graphitation/apollo-react-relay-duct-tape-compiler from:\n# ${sourcePath}\n` + + docText, }); } - if (buffer.length >= BATCH_SIZE) { + if (buffer.length >= READ_BATCH_SIZE) { flushBuffer(state); } } @@ -102,17 +111,26 @@ function resolvePath( } async function flushBuffer(state: EmitArtifactState) { - const start = performance.now(); - const promises = state.buffer.map((e) => writeWithRetry(e.file, e.data)); + const promises = state.buffer.map((entry) => writeWithRetry(state, entry)); state.buffer.length = 0; const promise = Promise.all(promises); state.activeFlushes.add(promise); await promise; - state.totalTime += performance.now() - start; state.activeFlushes.delete(promise); } -async function writeWithRetry(file: string, data: string) { +async function writeWithRetry( + state: EmitArtifactState, + { file, data }: { file: string; data: string }, +) { + // Ensure dir exists once per session + const dir = dirname(file); + if (!state.ensuredDirs.has(dir)) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + state.ensuredDirs.add(dir); + } try { await writeFile(file, data); } catch (e) {