@graphox/babel-plugin
Advanced tools
+108
-0
@@ -6,2 +6,3 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
| import os from 'os'; | ||
| import { execFileSync } from 'child_process'; | ||
| import plugin from './index.js'; | ||
@@ -187,2 +188,109 @@ | ||
| }); | ||
| it('rewrites graphql calls when outputDir is relative and cwd differs from package root', () => { | ||
| const rootDir = tmpDir; | ||
| const pkgDir = path.join(rootDir, 'packages', 'app'); | ||
| const outputDir = './src/generated'; | ||
| const absoluteOutputDir = path.join(pkgDir, 'src', 'generated'); | ||
| fs.mkdirSync(absoluteOutputDir, { recursive: true }); | ||
| fs.writeFileSync( | ||
| path.join(pkgDir, 'package.json'), | ||
| JSON.stringify({ name: 'app' }), | ||
| ); | ||
| fs.writeFileSync( | ||
| path.join(absoluteOutputDir, 'manifest.json'), | ||
| JSON.stringify(manifestData), | ||
| ); | ||
| const code = | ||
| "import { graphql } from './generated/graphql'; const q = graphql(`query { me { id } }`);"; | ||
| const pluginPath = path.join(process.cwd(), 'index.js'); | ||
| const presetPath = require.resolve('@babel/preset-typescript'); | ||
| const filePath = path.join(pkgDir, 'src', 'test.ts'); | ||
| const output = execFileSync( | ||
| process.execPath, | ||
| [ | ||
| '-e', | ||
| ` | ||
| const babel = require('@babel/core'); | ||
| const plugin = require(${JSON.stringify(pluginPath)}); | ||
| process.chdir(${JSON.stringify(rootDir)}); | ||
| const result = babel.transformSync(${JSON.stringify(code)}, { | ||
| plugins: [[plugin, { outputDir: ${JSON.stringify(outputDir)} }]], | ||
| presets: [${JSON.stringify(presetPath)}], | ||
| filename: ${JSON.stringify(filePath)}, | ||
| babelrc: false, | ||
| configFile: false, | ||
| }); | ||
| process.stdout.write(result.code); | ||
| `, | ||
| ], | ||
| { | ||
| cwd: process.cwd(), | ||
| encoding: 'utf8', | ||
| }, | ||
| ); | ||
| expect(output).toContain( | ||
| 'import { MyQueryDocument } from "./generated/query.codegen";', | ||
| ); | ||
| }); | ||
| it('rewrites graphql calls when manifestPath is relative and cwd differs from package root', () => { | ||
| const rootDir = tmpDir; | ||
| const pkgDir = path.join(rootDir, 'packages', 'app'); | ||
| const outputDir = './src/generated'; | ||
| const absoluteOutputDir = path.join(pkgDir, 'src', 'generated'); | ||
| const relativeManifestPath = './custom-manifest.json'; | ||
| const absoluteManifestPath = path.join(pkgDir, 'custom-manifest.json'); | ||
| fs.mkdirSync(absoluteOutputDir, { recursive: true }); | ||
| fs.writeFileSync( | ||
| path.join(pkgDir, 'package.json'), | ||
| JSON.stringify({ name: 'app' }), | ||
| ); | ||
| fs.writeFileSync( | ||
| absoluteManifestPath, | ||
| JSON.stringify(manifestData), | ||
| ); | ||
| const code = | ||
| "import { graphql } from './generated/graphql'; const q = graphql(`query { me { id } }`);"; | ||
| const pluginPath = path.join(process.cwd(), 'index.js'); | ||
| const presetPath = require.resolve('@babel/preset-typescript'); | ||
| const filePath = path.join(pkgDir, 'src', 'test.ts'); | ||
| const output = execFileSync( | ||
| process.execPath, | ||
| [ | ||
| '-e', | ||
| ` | ||
| const babel = require('@babel/core'); | ||
| const plugin = require(${JSON.stringify(pluginPath)}); | ||
| process.chdir(${JSON.stringify(rootDir)}); | ||
| const result = babel.transformSync(${JSON.stringify(code)}, { | ||
| plugins: [[plugin, { | ||
| outputDir: ${JSON.stringify(outputDir)}, | ||
| manifestPath: ${JSON.stringify(relativeManifestPath)} | ||
| }]], | ||
| presets: [${JSON.stringify(presetPath)}], | ||
| filename: ${JSON.stringify(filePath)}, | ||
| babelrc: false, | ||
| configFile: false, | ||
| }); | ||
| process.stdout.write(result.code); | ||
| `, | ||
| ], | ||
| { | ||
| cwd: process.cwd(), | ||
| encoding: 'utf8', | ||
| }, | ||
| ); | ||
| expect(output).toContain( | ||
| 'import { MyQueryDocument } from "./generated/query.codegen";', | ||
| ); | ||
| }); | ||
@@ -189,0 +297,0 @@ it('ignores tsconfig paths that do not point to output dir', () => { |
+220
-24
@@ -62,4 +62,18 @@ const path = require('path'); | ||
| const absoluteOutputDir = path.resolve(outputDir); | ||
| const currentFile = state.file.opts.filename; | ||
| const currentDir = currentFile ? path.dirname(currentFile) : process.cwd(); | ||
| const tsconfigPath = findNearestFile(currentDir, 'tsconfig.json'); | ||
| const pkgJsonPath = findNearestFile(currentDir, 'package.json'); | ||
| const rootDir = (pkgJsonPath || tsconfigPath) ? path.dirname(pkgJsonPath || tsconfigPath) : process.cwd(); | ||
| const absoluteOutputDir = path.isAbsolute(outputDir) | ||
| ? outputDir | ||
| : path.resolve(rootDir, outputDir); | ||
| // Resolve manifestPath relative to rootDir if provided as relative | ||
| if (manifestPath && !path.isAbsolute(manifestPath)) { | ||
| manifestPath = path.resolve(rootDir, manifestPath); | ||
| } | ||
| // Default manifestPath if not provided | ||
@@ -70,9 +84,5 @@ if (!manifestPath && !manifestData) { | ||
| const currentFile = state.file.opts.filename; | ||
| const currentDir = currentFile ? path.dirname(currentFile) : process.cwd(); | ||
| // Auto-detect import paths from tsconfig.json and package.json | ||
| const importPathsSet = new Set(configuredImportPaths.map(toPosixPath)); | ||
| const tsconfigPath = findNearestFile(currentDir, 'tsconfig.json'); | ||
| if (tsconfigPath) { | ||
@@ -85,3 +95,2 @@ const paths = resolveTsConfigPaths(tsconfigPath, absoluteOutputDir); | ||
| const pkgJsonPath = findNearestFile(currentDir, 'package.json'); | ||
| if (pkgJsonPath) { | ||
@@ -194,2 +203,111 @@ const imports = resolvePackageJsonImports(pkgJsonPath, absoluteOutputDir); | ||
| const getStaticString = (node) => { | ||
| if (t.isStringLiteral(node)) { | ||
| return node.value; | ||
| } | ||
| if ( | ||
| t.isTemplateLiteral(node) && | ||
| node.expressions.length === 0 && | ||
| node.quasis.length === 1 | ||
| ) { | ||
| return node.quasis[0].value.cooked || node.quasis[0].value.raw; | ||
| } | ||
| return null; | ||
| }; | ||
| const getRelativeImportPath = (entryPath) => { | ||
| const codegenAbsPath = path.join(absoluteOutputDir, entryPath); | ||
| let relPath = path.relative(path.dirname(currentFile), codegenAbsPath); | ||
| relPath = toPosixPath(relPath); | ||
| if (!relPath.startsWith('.') && !path.isAbsolute(relPath)) { | ||
| relPath = './' + relPath; | ||
| } | ||
| return relPath + extension; | ||
| }; | ||
| const getDynamicImportInfo = (exprPath) => { | ||
| if (!exprPath?.node) { | ||
| return null; | ||
| } | ||
| if (exprPath.isAwaitExpression()) { | ||
| const importCallPath = exprPath.get('argument'); | ||
| if (!importCallPath.isCallExpression()) { | ||
| return null; | ||
| } | ||
| if (!importCallPath.get('callee').isImport()) { | ||
| return null; | ||
| } | ||
| const [sourceArgPath] = importCallPath.get('arguments'); | ||
| if (!sourceArgPath?.node) { | ||
| return null; | ||
| } | ||
| return { awaited: true, sourceArgPath }; | ||
| } | ||
| if (!exprPath.isCallExpression() || !exprPath.get('callee').isImport()) { | ||
| return null; | ||
| } | ||
| const [sourceArgPath] = exprPath.get('arguments'); | ||
| if (!sourceArgPath?.node) { | ||
| return null; | ||
| } | ||
| return { awaited: false, sourceArgPath }; | ||
| }; | ||
| const buildDynamicImportRewrite = (requests, scope) => { | ||
| const moduleNameByPath = new Map(); | ||
| const uniquePaths = []; | ||
| for (const { sourcePath } of requests) { | ||
| if (!moduleNameByPath.has(sourcePath)) { | ||
| moduleNameByPath.set(sourcePath, scope.generateUid('graphoxModule')); | ||
| uniquePaths.push(sourcePath); | ||
| } | ||
| } | ||
| if (uniquePaths.length === 1) { | ||
| return t.callExpression(t.import(), [t.stringLiteral(uniquePaths[0])]); | ||
| } | ||
| const imports = uniquePaths.map((sourcePath) => | ||
| t.callExpression(t.import(), [t.stringLiteral(sourcePath)]) | ||
| ); | ||
| const moduleParams = uniquePaths.map((sourcePath) => | ||
| t.identifier(moduleNameByPath.get(sourcePath)) | ||
| ); | ||
| const properties = requests.map(({ importedName, sourcePath }) => | ||
| t.objectProperty( | ||
| t.identifier(importedName), | ||
| t.memberExpression( | ||
| t.identifier(moduleNameByPath.get(sourcePath)), | ||
| t.identifier(importedName), | ||
| ), | ||
| ), | ||
| ); | ||
| return t.callExpression( | ||
| t.memberExpression( | ||
| t.callExpression( | ||
| t.memberExpression(t.identifier('Promise'), t.identifier('all')), | ||
| [t.arrayExpression(imports)], | ||
| ), | ||
| t.identifier('then'), | ||
| ), | ||
| [ | ||
| t.arrowFunctionExpression( | ||
| [t.arrayPattern(moduleParams)], | ||
| t.objectExpression(properties), | ||
| ), | ||
| ], | ||
| ); | ||
| }; | ||
| const graphqlIds = new Set(); | ||
@@ -266,10 +384,3 @@ // newImports stores: localName -> { sourcePath, importedName } | ||
| const entry = documentNameToEntry.get(importedName); | ||
| const codegenAbsPath = path.join(absoluteOutputDir, entry.path); | ||
| let relPath = path.relative(path.dirname(currentFile), codegenAbsPath); | ||
| relPath = toPosixPath(relPath); | ||
| if (!relPath.startsWith('.') && !path.isAbsolute(relPath)) { | ||
| relPath = './' + relPath; | ||
| } | ||
| // Append the emit extension | ||
| relPath += extension; | ||
| const relPath = getRelativeImportPath(entry.path); | ||
@@ -339,10 +450,3 @@ // If the original import was aliased (e.g. import { D as MyD }), | ||
| const codegenAbsPath = path.join(absoluteOutputDir, entry.path); | ||
| let relPath = path.relative(path.dirname(currentFile), codegenAbsPath); | ||
| relPath = toPosixPath(relPath); | ||
| if (!relPath.startsWith('.') && !path.isAbsolute(relPath)) { | ||
| relPath = './' + relPath; | ||
| } | ||
| // Append the emit extension | ||
| relPath += extension; | ||
| const relPath = getRelativeImportPath(entry.path); | ||
@@ -357,2 +461,70 @@ const uniqueLocalName = getLocalName(entry.name, callPath.scope); | ||
| // Third pass: rewrite supported dynamic import patterns from graphql.ts/index.ts. | ||
| programPath.traverse({ | ||
| VariableDeclarator(varPath) { | ||
| const initPath = varPath.get('init'); | ||
| const info = getDynamicImportInfo(initPath); | ||
| if (!info) { | ||
| return; | ||
| } | ||
| const source = getStaticString(info.sourceArgPath.node); | ||
| if (!source || !isOurGraphqlPath(source)) { | ||
| return; | ||
| } | ||
| const idPath = varPath.get('id'); | ||
| if (!idPath.isObjectPattern()) { | ||
| throw varPath.buildCodeFrameError( | ||
| `@graphox/babel-plugin could not fully rewrite this dynamic import from "${source}". ` + | ||
| 'Use object destructuring of named documents from the generated graphql entrypoint or split the import by document.', | ||
| ); | ||
| } | ||
| const requests = []; | ||
| for (const propertyPath of idPath.get('properties')) { | ||
| if (!propertyPath.isObjectProperty() || propertyPath.node.computed) { | ||
| throw propertyPath.buildCodeFrameError( | ||
| `@graphox/babel-plugin could not fully rewrite this dynamic import from "${source}". ` + | ||
| 'Use object destructuring of named documents from the generated graphql entrypoint or split the import by document.', | ||
| ); | ||
| } | ||
| const key = propertyPath.node.key; | ||
| const importedName = t.isIdentifier(key) | ||
| ? key.name | ||
| : t.isStringLiteral(key) | ||
| ? key.value | ||
| : null; | ||
| if (!importedName || importedName === 'graphql' || importedName === 'gql') { | ||
| throw propertyPath.buildCodeFrameError( | ||
| `@graphox/babel-plugin could not fully rewrite this dynamic import from "${source}". ` + | ||
| 'Use object destructuring of named documents from the generated graphql entrypoint or split the import by document.', | ||
| ); | ||
| } | ||
| const entry = documentNameToEntry.get(importedName); | ||
| if (!entry) { | ||
| throw propertyPath.buildCodeFrameError( | ||
| `@graphox/babel-plugin could not rewrite "${importedName}" from "${source}". ` + | ||
| 'Run Graphox codegen and ensure the manifest includes this document.', | ||
| ); | ||
| } | ||
| requests.push({ | ||
| importedName, | ||
| sourcePath: getRelativeImportPath(entry.path), | ||
| }); | ||
| } | ||
| if (requests.length === 0) { | ||
| return; | ||
| } | ||
| const replacement = buildDynamicImportRewrite(requests, varPath.scope); | ||
| initPath.replaceWith(info.awaited ? t.awaitExpression(replacement) : replacement); | ||
| }, | ||
| }); | ||
| // Ensure we never remove the entrypoint import while runtime references still exist. | ||
@@ -377,4 +549,28 @@ programPath.traverse({ | ||
| // Third pass: remove ALL imports from graphql.ts/index.ts | ||
| // Reject runtime dynamic imports we could not rewrite before the entrypoint is cleared. | ||
| programPath.traverse({ | ||
| CallExpression(callPath) { | ||
| if (!callPath.get('callee').isImport()) { | ||
| return; | ||
| } | ||
| const [sourceArgPath] = callPath.get('arguments'); | ||
| if (!sourceArgPath?.node) { | ||
| return; | ||
| } | ||
| const source = getStaticString(sourceArgPath.node); | ||
| if (!source || !isOurGraphqlPath(source)) { | ||
| return; | ||
| } | ||
| throw callPath.buildCodeFrameError( | ||
| `@graphox/babel-plugin could not fully rewrite this dynamic import from "${source}". ` + | ||
| 'Use object destructuring of named documents from the generated graphql entrypoint or split the import by document.', | ||
| ); | ||
| }, | ||
| }); | ||
| // Fourth pass: remove ALL imports from graphql.ts/index.ts | ||
| programPath.traverse({ | ||
| ImportDeclaration(importPath) { | ||
@@ -381,0 +577,0 @@ const src = importPath.node.source.value; |
+76
-0
@@ -212,2 +212,78 @@ import { describe, it, expect } from 'vitest'; | ||
| it('rewrites dynamic import destructuring from graphql.js to the codegen module', () => { | ||
| const manifest = [ | ||
| { | ||
| source: 'mutation CreatePlaybackClient { createPlaybackClient { id } }', | ||
| path: './CreatePlaybackClientMutation.codegen', | ||
| name: 'CreatePlaybackClientDocument', | ||
| }, | ||
| ]; | ||
| const outputDir = path.resolve('/root/gen'); | ||
| const filename = '/root/app/TokenManager.ts'; | ||
| const output = transform( | ||
| ` | ||
| async function load() { | ||
| const { CreatePlaybackClientDocument } = await import('../gen/graphql.js'); | ||
| return CreatePlaybackClientDocument; | ||
| } | ||
| `, | ||
| { manifestData: manifest, outputDir }, | ||
| filename, | ||
| ); | ||
| expect(output).toContain('CreatePlaybackClientDocument'); | ||
| expect(output).toContain('await import("../gen/CreatePlaybackClientMutation.codegen")'); | ||
| expect(output).not.toContain('graphql.js'); | ||
| }); | ||
| it('rewrites multi-document dynamic imports across codegen files', () => { | ||
| const manifest = [ | ||
| { | ||
| source: 'query GetUser { user { id } }', | ||
| path: './user.codegen', | ||
| name: 'GetUserDocument', | ||
| }, | ||
| { | ||
| source: 'query GetPost { post { id } }', | ||
| path: './post.codegen', | ||
| name: 'GetPostDocument', | ||
| }, | ||
| ]; | ||
| const output = transform( | ||
| ` | ||
| async function load() { | ||
| const { GetUserDocument, GetPostDocument } = await import('./gen/graphql.js'); | ||
| return [GetUserDocument, GetPostDocument]; | ||
| } | ||
| `, | ||
| { manifestData: manifest, outputDir: './gen' }, | ||
| 'test.ts', | ||
| ); | ||
| expect(output).toContain('Promise.all([import("./gen/user.codegen"), import("./gen/post.codegen")])'); | ||
| expect(output).toMatch(/GetUserDocument:\s*_graphoxModule\d*\.GetUserDocument/); | ||
| expect(output).toMatch(/GetPostDocument:\s*_graphoxModule\d*\.GetPostDocument/); | ||
| expect(output).not.toContain('graphql.js'); | ||
| }); | ||
| it('throws on unsupported dynamic import namespaces from the graphql entrypoint', () => { | ||
| const manifest = [ | ||
| { | ||
| source: 'query GetUser { user { id } }', | ||
| path: './user.codegen', | ||
| name: 'GetUserDocument', | ||
| }, | ||
| ]; | ||
| const code = ` | ||
| async function load() { | ||
| const docs = await import('./gen/graphql.js'); | ||
| return docs.GetUserDocument; | ||
| } | ||
| `; | ||
| expect(() => transform(code, { manifestData: manifest, outputDir: './gen' })).toThrow( | ||
| /could not fully rewrite this dynamic import from "\.\/gen\/graphql\.js"/, | ||
| ); | ||
| }); | ||
| describe('emit extensions', () => { | ||
@@ -214,0 +290,0 @@ it('appends .ts extension when emitExtensions is "ts"', () => { |
+1
-1
| { | ||
| "name": "@graphox/babel-plugin", | ||
| "version": "0.4.4", | ||
| "version": "0.5.0", | ||
| "description": "Babel plugin for Graphox codesplitting", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Explicitly Unlicensed Item
LicenseSomething was found which is explicitly marked as unlicensed.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
70041
23.68%1546
27.24%5
66.67%1
Infinity%