🏸

NodejsFunctionでjsdom使おうとしたら「"./xhr-sync-worker.js" should be..」エラー

2023/09/14に公開

副題

プラグインを導入したesbuildを用いたNodejsFunctionを使いたい

ポイント

環境

aws-cdk@2.84.0
jsdom@22.1.0

背景

JSDOMを叩くNodeランタイムのlambdaをAWS CDK (NodejsFunction)で構築しようとした
lambdaコード

exports.handler = (event, _context) => {
  import { JSDOM } from 'jsdom'
  const dom = new JSDOM(html)
  ...
}

CDKコード

import * as nodeLambda from 'aws-cdk-lib/aws-lambda-nodejs'
const function = nodeLambda.NodejsFunction(scope, id, {
  entry: path.join(__dirname, './lambda/index.ts'),
  handler: 'handler',
  ...
})

結果、esbuildによるビルド時に以下エラーが発生した

Warning: G] "./xhr-sync-worker.js" should be marked as external for use with "require.resolve" [require-resolve-not-external]

    node_modules/jsdom/lib/jsdom/living/xhr/XMLHttpRequest-impl.js:31:57:
      31... require.resolve ? require.resolve("./xhr-sync-worker.js") : null;

external moduleに指定しろと言っているが、そういう問題ではなさそう
warningなんで試しに無視して動かしてみたが、案の定runtime error

原因

JSDOM中の以下記述における、相対パスのrequire.resolveにesbuildが対応できない
const syncWorkerFile = require.resolve ? require.resolve("./xhr-sync-worker.js") : null;

対策

1. jsdomPatchPlugin.cjs を作成

const fs = require('fs')
const jsdomPatch = {
  name: 'jsdom-patch',
  setup(build) {
  // jsdomのバージョンアップに伴い、以下のfilterの正規表現、置換後の文字列を変更する必要がある
  build.onLoad({ filter: /XMLHttpRequest-impl\.js$/ }, async (args) => {
    let contents = await fs.promises.readFile(args.path, 'utf8')
    contents = contents.replace(
      'const syncWorkerFile = require.resolve ? require.resolve("./xhr-sync-worker.js") : null;',
      `const syncWorkerFile = "${require.resolve('jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js')}";`
    )
    return { contents, loader: 'js' }
  })
}
}

module.exports = jsdomPatch

2. build.mjs を作成

import esbuild from 'esbuild'
import jsdomPatch from './jsdomPatchPlugin.cjs'

await esbuild
  .build({
    // jsdomPatch用のpluginを使うように設定
    plugins: [jsdomPatch],
  })
  .catch((error) => {
    console.log(error)
    process.exit(1)
  })

3. customEsbuildFunction.ts を作成 (lambda.Functionを継承したカスタムコンストラクタ)

import { IBuildProvider, ProviderBuildOptions, TypeScriptCode } from '@mrgrain/cdk-esbuild'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import { spawnSync } from 'child_process'
import { Construct } from 'constructs'
import * as path from 'path'

interface CustomEsbuildFunctionProps {
  code: string
  functionProps: Omit<lambda.FunctionProps, 'code'>
}

class BuildScriptProvider implements IBuildProvider {
  constructor(public readonly scriptPath: string) {}
  buildSync(options: ProviderBuildOptions): void {
    // spawnSyncでプラグイン導入したesbuildを実行
    const result = spawnSync('node', [this.scriptPath, JSON.stringify(options)], {
      stdio: ['inherit', 'pipe']
    })

    if (result.stderr.byteLength > 0) {
      throw new Error(`${result.stderr.toString()}`)
    }
  }
}

export class CustomEsbuildFunction extends lambda.Function {
  constructor(scope: Construct, id: string, { code, functionProps }: CustomEsbuildFunctionProps) {
    super(scope, id, {
      ...functionProps,
      // カスタムesbuildでトランスパイル&バンドルしたJSコードをlambdaのコードとして指定する
      // 細かいところは全てTypeScriptCodeが実行してくれる
      code: new TypeScriptCode(code, { buildProvider: new BuildScriptProvider(path.join(__dirname, 'build.mjs')) })
    })
  }
}

4. CustomEsbuildFunctionを呼び出す

export class CdkStack extends Stack {
  constructor(scope: Construct, id: string, props: RemsStackProps) {
    super(scope, id, props)
    const lambdaFunction = new CustomEsbuildFunction(this, 'id', {
      code: path.join(__dirname, './lambda/index.ts'),
      functionProps: {
        runtime: lambda.Runtime.NODEJS_18_x,
	handler: 'index.handler',
	...
      }
    })
  }
}

なにやってるの?

相対パスで記述された部分をesbuildのプリプロセスでフルパスの記述に置き換えています

let contents = await fs.promises.readFile(args.path, 'utf8')
contents = contents.replace(
  'const syncWorkerFile = require.resolve ? require.resolve("./xhr-sync-worker.js") : null;',
  `const syncWorkerFile = "${require.resolve('jsdom/lib/jsdom/living/xhr/xhr-sync-worker.js')}";`
)

JSDOMのバージョンによってこの記述は変わる可能性があります。
(事実、ちょいちょい変わってます)

補足

  1. esbuildのプラグインについてはこちらのブログが参考になりました
  1. cjs, mjsが入り乱れているのは海よりも深い理由があります
  • そんな深くないですが、mjs統一に失敗したので諦めました。
  • TypeScript化はもっと諦めています(spawnSyncで別途build.ts, plugin.tsもトランスパイルするプロセスを追加すればいけるかも?)
  1. 4-5時間溶かした結果です。どなたかの救いになれば。

Discussion