Closed8

[メモ] Subscriptions in Apollo Server の構築で躓いた点&解決履歴(流れでESM対応+Prisma UT)

SYMSYM

2022/9/3時点

前提

  "dependencies": {
    "@graphql-tools/graphql-file-loader": "^7.5.3",
    "@graphql-tools/load": "^7.7.5",
    "@graphql-tools/schema": "^9.0.2",
    "@prisma/client": "^4.3.1",
    "apollo-server": "^3.10.2",
    "apollo-server-core": "^3.10.2",
    "bcrypt": "^5.0.1",
    "graphql": "^16.6.0",
    "graphql-subscriptions": "^2.0.0",
    "graphql-ws": "^5.10.1",
    "jsonwebtoken": "^8.5.1",
    "ws": "^8.8.1"
  },
  • プロジェクトの初期構築には gts を利用。(Apollo+Prismaで簡易なGraphQLサーバ開発をやり始める直前位に存在を知ったため試しに使ってみることにした)当時のgtsのtsconfigの設定内容は以下の通りであった
{
  "compilerOptions": {
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "declaration": true,
    "forceConsistentCasingInFileNames": true,
    "lib": ["es2018"],
    "module": "commonjs",
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "pretty": true,
    "sourceMap": true,
    "strict": true,
    "target": "es2018"
  },
  "exclude": [
    "node_modules"
  ]
}

ある程度作ってから、Apollo の公式ドキュメント( Subscriptions in Apollo Server ) を試そうと index.ts を書き換えた時にいくつか引っかかった

公式ドキュメントからのそのままコピペだが、当時のコードを貼っておく(ドキュメントの内容変わる可能性もあるため)

import { ApolloServer } from 'apollo-server-express';
import { createServer } from 'http';
import express from 'express';
import { ApolloServerPluginDrainHttpServer } from "apollo-server-core";
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import resolvers from './resolvers';
import typeDefs from './typeDefs';

// Create the schema, which will be used separately by ApolloServer and
// the WebSocket server.
const schema = makeExecutableSchema({ typeDefs, resolvers });

// Create an Express app and HTTP server; we will attach both the WebSocket
// server and the ApolloServer to this HTTP server.
const app = express();
const httpServer = createServer(app);

// Create our WebSocket server using the HTTP server we just set up.
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});
// Save the returned server's info so we can shutdown this server later
const serverCleanup = useServer({ schema }, wsServer);

// Set up ApolloServer.
const server = new ApolloServer({
  schema,
  csrfPrevention: true,
  cache: "bounded",
  plugins: [
    // Proper shutdown for the HTTP server.
    ApolloServerPluginDrainHttpServer({ httpServer }),

    // Proper shutdown for the WebSocket server.
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
    ApolloServerPluginLandingPageLocalDefault({ embed: true }),
  ],
});
await server.start();
server.applyMiddleware({ app });

const PORT = 4000;
// Now that our HTTP server is fully set up, we can listen to it.
httpServer.listen(PORT, () => {
  console.log(
    `Server is now running on http://localhost:${PORT}${server.graphqlPath}`,
  );
});

ref:
https://www.apollographql.com/docs/apollo-server/data/subscriptions/#enabling-subscriptions

SYMSYM

エラー1

(しょうもないが)ApolloServer の import するものを変える必要があった

以下のメソッドの引数は0個と怒られた

server.applyMiddleware({ app });

applyMiddleware の中を見ると、apollo-server-express を使えと書いてあって気づいた

  public override applyMiddleware() {
    throw new Error(
      'To use Apollo Server with an existing express application, please use apollo-server-express',
    );
  }
  • 原因

単に import の変え忘れ

before

import { ApolloServer } from 'apollo-server';

after

import { ApolloServer } from 'apollo-server-express';

ドキュメントにしれっと書いてあったのを普通に見落とした(Oh, No...)

The following steps assume you've already swapped to apollo-server-express.

SYMSYM

エラー2

以下の await に対して

await server.start();

以下のエラーが出た

トップレベルの 'await' 式は、'module' オプションが 'es2022'、'esnext'、'system'、'node16' または 'nodenext' に設定されていて、'target' オプションが 'es2017' 以上に設定されている場合にのみ使用できます。

  • 原因

エラーに書いてある通り。 英語で出ていたエラー内容でそのままググったら以下を見つけ、

Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext'

以下のように書いてあった

module to es2022 (or higher)
target to es2017 (or higher) or ESNext

  • 修正

別に async 付けた関数でラップして呼び出せば回避できるものの、せっかくなので最新寄りにすることにした(gtsを使った意味…)

target は es2018 で満たしていたので、module のみを変更

{
  "extends": "./node_modules/gts/tsconfig-google.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "build",
    "module": "es2022"  // ここを追加
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}
SYMSYM

エラー3

↑ を直して実行すると以下のようなエラーが出た

$ npm run dev

> backend@1.0.0 dev
> ts-node-dev --respawn src/index.ts
Compilation error in D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\src\index.ts

[ERROR] 01:10:21 ⨯ Unable to compile TypeScript:
src/index.ts(1,33): error TS2792: Cannot find module '@graphql-tools/graphql-file-loader'. Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option?
  : 
// 多いので省略
  • 原因

エラーに出ている通り、 "moduleResolution": "node" が足りない

エラー内容でもググっても出てきた
...Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option?と出た時の対処法

  • 修正

tsconfig.json に追加

{
  "extends": "./node_modules/gts/tsconfig-google.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "build",
    "module": "es2022",
    "moduleResolution": "node"  // ここを追加
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}
SYMSYM

エラー4

↑を直すと、今度はこんなエラーが出た

(node:33888) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
<project full path>\backend\src\index.ts:1
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
^^^^^^

SyntaxError: Cannot use import statement outside a module
  : (stack trace は省略
  • 原因

エラーに出ている通り、package.jsonに ”type”: “module” が必要であった

エラー内容でググって見つけた

node.js エラー「SyntaxError: Cannot use import statement outside a module」が発生した場合の対処法

SYMSYM

エラー5

↑ を直すと、今度は以下のようなエラーが出た

Error: Must use import to load ES Module: <project full path>\backend\src\index.ts
  • 原因

commonjs が関係あるらしい?

Please read the release notes. Issues like these are usually caused by your build tool incorrectly transpiling dependencies like Got to CommonJS.
和訳:リリースノートを読んでください。このような問題は通常、Got to CommonJSのような依存関係をビルドツールが正しくトランスパイルしないことが原因です。

ref: https://github.com/sindresorhus/got/issues/1956

上記 issue に添付のリリースノートを見ると、以下の部分に関係がありそうと

https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#im-having-problems-with-esm-and-typescript

  • 修正

リリースノートに添付されていたガイドに従い module を変更。moduleResolution も不要だったので削除(情報源ロスト)

{
  "extends": "./node_modules/gts/tsconfig-google.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "build",
    "module": "NodeNext",
  },
  "include": ["src/**/*.ts", "test/**/*.ts"],
}

使用していた ts-node-dev が良くなさそうな気がした(esmに対応してないのでは?という推測)ので、一旦使用をやめる。それに伴い、package.json の scripts に書いていたものを変更

before

  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts",

after

  "scripts": {
    "dev": "ts-node --esm src/index.ts",

ガイドや、以下を参考にしながら、esm に対応のため、ソースを修正

参考:

補足

  • ts-node-dev は まだ esm に対応しきれていなさそう?
    • 2022/9/4時点で Error when using ESM that works with ts-node #212 の issue が解決していない
    • ts-node-dev -r esm xxx でできるよとも別のissueに書いてあったが、自分の環境ではできず同じエラー発生(成功/失敗する条件が何かあるのか?)
SYMSYM

というところまでやって無事解決した

解決後のソース一式:https://github.com/Symthy/GraphQL-practices/tree/6db17f4d4f46fcc812ccf6e9307e76733617e75d/graphql-hacker-news-clone/backend

Ending

ここまでやって気づくのだ

果たして本当に ESM 対応 しないといけなかったのか?

tsconfig.jsonの "module" が "esnext" とかでも、うまくいく方法あるのではないか?と…

module 周り良くわかっていないので理解しないと、↑ の疑問は解消しないかもしれない

ESMちゃんと分かっていないので勉強要。以下が為になりそう。

SYMSYM

おまけ (Prisma Unit Test 導入の躓き&解決履歴)

以下を実施

https://www.prisma.io/docs/guides/testing/unit-testing

前提

https://jestjs.io/ja/docs/getting-started#ts-jest-経由で

を元に、必要なものをインストール npm i -D jest ts-jest @types/jest

    "@types/jest": "^29.0.0",
    "jest": "^28.1.3",
    "jest-mock-extended": "^2.0.7",
    "prisma": "^4.3.1",
    "ts-jest": "^28.0.8",

prisma ドキュメントでは jest-mock-extended: 2.0.4 では動いたと書いてあったが

  • jest-mock-extended 2.0.4 は jest 27 以下
  • ts-jest は 28以上

を要求してきて衝突したので、jest-mock-extended を jest 28 に対応済みのバージョンに上げた(2.0.4~2.0.7 の間で行われた修正内容的に、jest28の対応以外はそこまで大したことはなさそうだったので恐らく大丈夫だろうとの判断)

エラー1

$ npx jest
ReferenceError: module is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and 'D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
    at file:///D:/dev_git/GraphQL-practices/graphql-hacker-news-clone/backend/jest.config.js:2:1
    at ModuleJob.run (node:internal/modules/esm/module_job:198:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:385:24)
    at async importModuleDynamicallyWrapper (node:internal/vm/module:437:15)
    at async requireOrImportModule (D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\node_modules\jest-config\node_modules\jest-util\build\requireOrImportModule.js:65:32)
    at async readConfigFileAndSetRootDir (D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\node_modules\jest-config\build\readConfigFileAndSetRootDir.js:132:22)
    at async readConfig (D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\node_modules\jest-config\build\index.js:216:18)
    at async readConfigs (D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\node_modules\jest-config\build\index.js:404:26)
    at async runCLI (D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\node_modules\@jest\core\build\cli\index.js:140:59)

多分 jest に esm の設定できてない&必要なんだろうと推測

(情報減ロスト) jest.confgi.js に以下を記載すればよいとのことで記載

export default {
  preset: "ts-jest/presets/default-esm",
  globals: {
    "ts-jest": {
      useESM: true,
    },
  },
}

エラー2

$ npx jest
ts-jest[config] (WARN) message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 FAIL  test/prisma.test.ts
  ● Test suite failed to run
                                                                                                                                                    
    Cannot find module '../lib/prisma.js' from 'test/prisma.test.ts'

      1 | import {describe, expect, test} from '@jest/globals';
    > 2 | import prisma from '../lib/prisma.js'
        | ^
      3 | import { prismaMock } from './singleton.js'
      4 |
      5 | interface CreateUser {

      at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:491:11)
      at Object.<anonymous> (test/prisma.test.ts:2:1)

フォルダ構成は以下のようにしていた

<rootDir>
 |- src
     |- lib
         |- prisma.ts  // PrismaClient のインスタンスを export default するだけ 
     |- test
         |- singleton.ts  // prismaのドキュメントからのコピペ
         |- prisma.test.ts  // prismaのドキュメントのテストコードのコピペ

..のパス解決 がうまくいってなさそうだと判断

js.config.js に以下を追加すればOK

  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1'
  },

ref: https://zenn.dev/hankei6km/articles/native-esm-with-typescript-jest

エラー3

ドキュメントからそのままコピペしたテストがなぜか失敗する

$ npx jest
ts-jest[config] (WARN) message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 FAIL  src/test/prisma.test.ts
  prisma test
    × should create new user  (33 ms)                                                                                                               
    √ should update a users name  (194 ms)                                                                                                          
                                                                                                                                                    
  ● prisma test › should create new user                                                                                                            
                                                                                                                                                    
    expect(received).resolves.toEqual()

    Received promise rejected instead of resolved
    Rejected to value: [Error:·
    Invalid `prisma.user.create()` invocation in
    D:\dev_git\GraphQL-practices\graphql-hacker-news-clone\backend\src\test\prisma.test.ts:13:28·
      10 }
      11·
      12 export async function createUser(user: CreateUser) {
    → 13   return await prisma.user.create(
    Unique constraint failed on the fields: (`id`)]

      40 |     prismaMock.user.create.mockResolvedValue(user)
      41 |
    > 42 |     await expect(createUser(user)).resolves.toEqual({
         |                 ^
      43 |       id: 1,
      44 |       name: 'Rich',
      45 |       email: 'hello@prisma.io',

      at expect (node_modules/@jest/expect/node_modules/expect/build/index.js:128:15)
      at Object.<anonymous> (src/test/prisma.test.ts:42:17)

解決策見つけるのに苦戦した

Unique constraint failed on the fields "prismaMock" とググってようやく解決法を見つけた(引用の2行目)

Help with PrismaMock which use db instead of mocking

  • Are you using Jest?
  • If so, did you add singleton.ts in setupFilesAfterEnv in the jest.config.js file?
  • If not, what setup are you using?

jest.config.js に以下を追加することで無事テスト成功

  setupFilesAfterEnv: [
    './src/test/singleton.ts'
  ]

(Jest 使い慣れていたら、躓かないんだろうな…。 持前の解決力でどうにかした)

このスクラップは2022/09/04にクローズされました