blitz-js がどうやってサーバー上の関数のクライアントでの呼び出しを実現しているのか、調査した

23 min読了の目安(約20900字TECH技術記事

blitz-js prisma rails 倒し方 を書いた時、こういう疑問がありました。

この db はクライアントでもサーバーでも呼べるようにみえる。ここが blitz のキモで、サーバーではそのまま prisma として実行されるが、内部実装を読んでいないので想像だが、 この db はクライアントでは同じ API の RPC に変換されている?
(ここにセキュリティ上の不安はある。すべてをクライアントから呼べてしまう恐れはないのか? あとで blitz のコードを呼んで、どうやって実現しているか確認する)

この件についての調査をしました。

Isomorphism: クライアント・サーバー同型

blitz では、次のようなコードをクライアントでもサーバーでも呼ぶことができます。

// app/auth/mutations/login.ts
import { Ctx } from "blitz"
import { authenticateUser } from "app/auth/auth-utils"
import { LoginInput, LoginInputType } from "../validations"

export default async function login(input: LoginInputType, { session }: Ctx) {
  // This throws an error if input is invalid
  const { email, password } = LoginInput.parse(input)

  // This throws an error if credentials are invalid
  const user = await authenticateUser(email, password)

  await session.create({ userId: user.id, roles: [user.role] })

  return user
}

この login(...) 関数は、クライアントからでもサーバーサイドからでも呼ぶことができます。prisma のデータベースを触るようなサーバサイドで実装した関数が、クライアントでも呼べる、これは一体どういうことでしょうか?

その謎を追うために、 blitz のソースコードを追ってみました。

結論だけ知りたい人向け: ビルド時にクラサバで分岐します。 getIsomorphicEnhancedHandler の解説だけ読めば十分です。

blitz bulid stage の理解

blitz には大きく分けて2つのビルドステージがあります。

  • app などのソースコードから .blitz に対するビルド
  • .blitz の中身を、 next.js のビルダーが .next に対して出力するビルド

開発用のビルドと本番用のビルドがあるのですが、今回は blitz build で行われる本番用ビルドの処理を追っていくとします。

生成結果から追う

blitz new myapp してから app 以下は次のような構成になっていることを確認します。

app
├── auth
│   ├── auth-utils.ts
│   ├── components
│   │   ├── LoginForm.tsx
│   │   └── SignupForm.tsx
│   ├── mutations
│   │   ├── login.ts
│   │   ├── logout.ts
│   │   └── signup.ts
│   ├── pages
│   │   ├── login.tsx
│   │   └── signup.tsx
│   └── validations.ts
├── components
│   ├── Form.tsx
│   └── LabeledTextField.tsx
├── hooks
│   └── useCurrentUser.ts
├── layouts
│   └── Layout.tsx
├── pages
│   ├── 404.tsx
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── index.test.tsx
│   └── index.tsx
└── users
    └── queries
        └── getCurrentUser.ts

この状態で、 blitz build をして生成される .blitz/cache/build 以下を見てみます。

├── app
│   ├── _resolvers
│   │   ├── auth
│   │   │   └── mutations
│   │   │       ├── login.ts
│   │   │       ├── logout.ts
│   │   │       └── signup.ts
│   │   └── users
│   │       └── queries
│   │           └── getCurrentUser.ts
│   ├── auth
│   │   ├── auth-utils.ts
│   │   ├── components
│   │   │   ├── LoginForm.tsx
│   │   │   └── SignupForm.tsx
│   │   ├── mutations
│   │   │   ├── login.ts
│   │   │   ├── logout.ts
│   │   │   └── signup.ts
│   │   └── validations.ts
│   ├── components
│   │   ├── Form.tsx
│   │   └── LabeledTextField.tsx
│   ├── hooks
│   │   └── useCurrentUser.ts
│   ├── layouts
│   │   └── Layout.tsx
│   └── users
│       └── queries
│           └── getCurrentUser.ts
├── pages
│   ├── 404.tsx
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── api
│   │   ├── auth
│   │   │   └── mutations
│   │   │       ├── login.ts
│   │   │       ├── logout.ts
│   │   │       └── signup.ts
│   │   └── users
│   │       └── queries
│   │           └── getCurrentUser.ts
│   ├── index.tsx
│   ├── login.tsx
│   └── signup.tsx
├── public
│   ├── favicon.ico
│   └── logo.png
├── types.ts
└── yarn.lock

ソースコードとしては、 app/{moduleName} 以下に queries | mutations | pages などを配置するのですが、まず pages を一つにマージして、 また、queries と mutations をマージしたものを、 app/_resolvers に配置しています。

これが next.js にどのようにビルドされるのでしょうか。 .next/server 以下を覗くと次のような構成になっています。

.next/server/pages
├── 404.html
├── _app.js
├── _document.js
├── _error.js
├── api
│   ├── auth
│   │   └── mutations
│   │       ├── login.js
│   │       ├── logout.js
│   │       └── signup.js
│   └── users
│       └── queries
│           └── getCurrentUser.js
├── index.html
├── login.html
└── signup.html

next.js において、 pages/api/* は API Route と呼ばれ、 HTML ではなく、 API として JSON などを返却するエンドポイントとなります。

API Routes: Introduction

この例だと、 /api/users/queries/getCurrentUser が定義されるわけですね。

つまり、クライアントで呼ぶ際は、 直接呼ばずにこのエンドポイントを経由してることが予想されます。

コードから追う

next.config.js をラップしてる with-blitz を見ます。

// packages/server/src/with-blitz.ts
import pkgDir from "pkg-dir"
import path from "path"
import fs from "fs"

export function withBlitz(nextConfig: any) {
  return (phase: string, nextOpts: any = {}) => {
    // Need to grab the normalized config based on the phase
    // we are in incase we are given a functional config to extend
    const normalizedConfig =
      typeof nextConfig === "function" ? nextConfig(phase, nextOpts) : nextConfig

    const newConfig = Object.assign({}, normalizedConfig, {
      experimental: {
        reactMode: "concurrent",
        ...(normalizedConfig.experimental || {}),
      },
      webpack(config: any, options: Record<any, any>) {
        if (options.isServer) {
          const originalEntry = config.entry
          config.entry = async () => ({
            ...(await originalEntry()),
            ...(doesDbModuleExist() ? {"../__db": "./db/index"} : {}),
          })
        } else {
          config.module = config.module || {}
          config.module.rules = config.module.rules || []
          config.module.rules.push({test: /_resolvers/, use: {loader: "null-loader"}})
          config.module.rules.push({test: /@blitzjs[\\/]display/, use: {loader: "null-loader"}})
          config.module.rules.push({test: /@blitzjs[\\/]config/, use: {loader: "null-loader"}})
          config.module.rules.push({test: /@prisma[\\/]client/, use: {loader: "null-loader"}})
          config.module.rules.push({test: /passport/, use: {loader: "null-loader"}})
          config.module.rules.push({test: /cookie-session/, use: {loader: "null-loader"}})
          config.module.rules.push({
            test: /blitz[\\/]packages[\\/]config/,
            use: {loader: "null-loader"},
          })
          config.module.rules.push({
            test: /blitz[\\/]packages[\\/]display/,
            use: {loader: "null-loader"},
          })
        }

        if (typeof normalizedConfig.webpack === "function") {
          return normalizedConfig.webpack(config, options)
        }

        return config
      },
    })

    function doesDbModuleExist() {
      const projectRoot = pkgDir.sync() || process.cwd()
      return (
        fs.existsSync(path.join(projectRoot, "db/index.js")) ||
        fs.existsSync(path.join(projectRoot, "db/index.ts")) ||
        fs.existsSync(path.join(projectRoot, "db/index.tsx"))
      )
    }

    // We add next-transpile-modules during internal blitz development so that changes in blitz
    // framework code will trigger a hot reload of any example apps that are running
    const isInternalBlitzDevelopment = __dirname.includes("packages/server/src")
    if (isInternalBlitzDevelopment) {
      const withTM = require("next-transpile-modules")(["@blitzjs/core", "@blitzjs/server"])
      return withTM(newConfig)
    } else {
      return newConfig
    }
  }
}

サーバー用はほぼ素通しですが、クライアント用にはサーバーサイドで動く部分のコードを null-loader で差し替えています。

実際にビルド処理を追っていきましょう。

// packages/server/src/build.ts
export async function build(
  config: ServerConfig,
  readyForNextBuild: Promise<any> = Promise.resolve(),
) {
  const {
    rootFolder,
    transformFiles,
    buildFolder,
    nextBin,
    ignore,
    include,
    watch,
    isTypescript,
    writeManifestFile,
  } = await normalize(config)

  const stages = configureStages({isTypescript, writeManifestFile})

  await Promise.all([
    transformFiles(rootFolder, stages, buildFolder, {
      ignore,
      include,
      watch,
      clean: true, // always clean in build
    }),
    readyForNextBuild,
  ])

  await nextBuild(nextBin, buildFolder)

  const rootNextFolder = resolve(rootFolder, ".next")
  const buildNextFolder = resolve(buildFolder, ".next")

  if (await pathExists(rootNextFolder)) {
    await remove(rootNextFolder)
  }

  if (await pathExists(buildNextFolder)) {
    await move(buildNextFolder, rootNextFolder)
  }

  await saveBuild(buildFolder)
}

この transformFiles は、 stages で定義された順番で、 transformFiles が実行されます。trnsformFiles 自体は、この file-pipeline パッケージが使われています。

[https://www.npmjs.com/package/@blitzjs/file-pipeline @blitzjs/file-pipeline - npm]

で、この stages 定義。

// packages/server/src/stages/index.ts
import {createStageConfig} from "./config"
import {createStageManifest} from "./manifest"
import {createStagePages} from "./pages"
import {createStageRelative} from "./relative"
import {createStageRpc} from "./rpc"

type StagesConfig = {writeManifestFile: boolean; isTypescript: boolean}

// These create pipeline stages that are run as the business rules for Blitz
// Read this folders README for more information
export const configureStages = (config: StagesConfig) => [
  // Order is important
  createStageRelative,
  createStagePages,
  createStageRpc(config.isTypescript),
  createStageConfig,
  createStageManifest(config.writeManifestFile),
]

ここで createStageRpc に注目します。

// packages/server/src/stages/rpc/index.ts
export const createStageRpc = (isTypescript = true): Stage =>
  function configure({config: {src}}) {
    const fileTransformer = absolutePathTransform(src)

    const getResolverPath = fileTransformer(resolverFilePath)
    const getApiHandlerPath = fileTransformer(apiHandlerPath)

    const stream = transform.file((file, {next, push}) => {
      if (!isResolverPath(file.path)) {
        return file
      }

      const originalPath = resolutionPath(src, file.path)
      const resolverImportPath = resolverFilePath(originalPath)
      const {resolverType, resolverName} = extractTemplateVars(resolverImportPath)

      // Original function -> _resolvers path
      push(
        new File({
          path: getResolverPath(file.path),
          contents: file.contents,
          // Appending a new file to the output of this particular stream
          // We don't want to reprocess this file but simply add it to the output
          // of the stream here we provide a hash with some information for how
          // this file came to be here
          hash: [file.hash, "rpc", "resolver"].join("|"),
          event: "add",
        }),
      )

      // File API route handler
      push(
        new File({
          path: getApiHandlerPath(file.path),
          contents: Buffer.from(apiHandlerTemplate(originalPath, isTypescript)),
          // Appending a new file to the output of this particular stream
          // We don't want to reprocess this file but simply add it to the output
          // of the stream here we provide a hash with some information for how
          // this file came to be here
          hash: [file.hash, "rpc", "handler"].join("|"),
          event: "add",
        }),
      )

      // Isomorphic client
      const isomorphicHandlerFile = file.clone()
      isomorphicHandlerFile.contents = Buffer.from(
        isomorhicHandlerTemplate(resolverImportPath, resolverName, resolverType),
      )
      push(isomorphicHandlerFile)

      return next()
    })

    return {stream}
  }

reslover 判定された実装は、 _resolversapiHandler, isomorphic client の3つに展開されます。

resolver 判定がこれ。

// packages/server/src/stages/rpc/index.ts
export function isResolverPath(filePath: string) {
  return /(?:app[\\/])(?!_resolvers).*(?:queries|mutations)[\\/].+/.exec(filePath)
}

API Handler

// packages/server/src/stages/rpc/index.ts
const apiHandlerTemplate = (originalPath: string, useTypes: boolean) => `
// This imports the output of getIsomorphicEnhancedResolver()
import enhancedResolver from '${originalPath}'
import {getAllMiddlewareForModule} from '@blitzjs/core'
import {rpcApiHandler} from '@blitzjs/server'
import path from 'path'

// Ensure these files are not eliminated by trace-based tree-shaking (like Vercel)
path.resolve("next.config.js")
path.resolve("blitz.config.js")
path.resolve(".next/__db.js")
// End anti-tree-shaking

let db${useTypes ? ": any" : ""}
let connect${useTypes ? ": any" : ""}
try {
  db = require('db').default
  connect = require('db').connect ?? (() => db.$connect ? db.$connect() : db.connect())
}catch(err){}
export default rpcApiHandler(
  enhancedResolver,
  getAllMiddlewareForModule(enhancedResolver),
  () => db && connect(),
)
export const config = {
  api: {
    externalResolver: true,
  },
}
`

next.js の API Routes で externalResolver: true を有効にして、 rpcApiHandler でラップします。

https://nextjs.org/docs/api-routes/api-middlewares

export function rpcApiHandler<TInput, TResult>(
  resolver: EnhancedResolver<TInput, TResult>,
  middleware: Middleware[] = [],
  connectDb?: () => any,
) {
  // RPC Middleware is always the last middleware to run
  middleware.push(rpcMiddleware(resolver, connectDb))

  return (req: BlitzApiRequest, res: BlitzApiResponse) => {
    return handleRequestWithMiddleware(req, res, middleware, {
      throwOnError: false,
    })
  }
}

blitz middleware を経由してから、関数を実行してるっぽい。

Isomorphic Client

isomorhicHandlerTemplate

const isomorhicHandlerTemplate = (
  resolverFilePath: string,
  resolverName: string,
  resolverType: ResolverType,
) => `
import {getIsomorphicEnhancedResolver} from '@blitzjs/core'
import * as resolverModule from '${resolverFilePath}'
export default getIsomorphicEnhancedResolver(
  resolverModule,
  '${resolverFilePath}',
  '${resolverName}',
  '${resolverType}',
)
`

getIsomorphicEnhancedResolver でラップした関数を返します。

getIsomorphicEnhancedResolver の実装

こういう実装です

export function getIsomorphicEnhancedResolver<TInput, TResult>(
  // resolver is undefined on the client
  resolver: ResolverModule<TInput, TResult> | undefined,
  resolverFilePath: string,
  resolverName: string,
  resolverType: ResolverType,
): EnhancedResolver<TInput, TResult> | EnhancedResolverRpcClient<TInput, TResult>
export function getIsomorphicEnhancedResolver<TInput, TResult>(
  // resolver is undefined on the client
  resolver: ResolverModule<TInput, TResult> | undefined,
  resolverFilePath: string,
  resolverName: string,
  resolverType: ResolverType,
  target: "client",
): EnhancedResolverRpcClient<TInput, TResult>
export function getIsomorphicEnhancedResolver<TInput, TResult>(
  // resolver is undefined on the client
  resolver: ResolverModule<TInput, TResult> | undefined,
  resolverFilePath: string,
  resolverName: string,
  resolverType: ResolverType,
  target: "server",
): EnhancedResolver<TInput, TResult>
export function getIsomorphicEnhancedResolver<TInput, TResult>(
  // resolver is undefined on the client
  resolver: ResolverModule<TInput, TResult> | undefined,
  resolverFilePath: string,
  resolverName: string,
  resolverType: ResolverType,
  target: "server" | "client" = isClient ? "client" : "server",
): EnhancedResolver<TInput, TResult> | EnhancedResolverRpcClient<TInput, TResult> {
  const apiUrl = getApiUrlFromResolverFilePath(resolverFilePath)
  if (target === "client") {
    const resolverRpc: ResolverRpc<TInput, TResult> = (params, opts) =>
      executeRpcCall(apiUrl, params, opts)
    const enhancedResolverRpcClient = resolverRpc as EnhancedResolverRpcClient<TInput, TResult>

    enhancedResolverRpcClient._meta = {
      name: resolverName,
      type: resolverType,
      filePath: resolverFilePath,
      apiUrl: apiUrl,
    }

    // Warm the lambda
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    executeRpcCall.warm(apiUrl)

    return enhancedResolverRpcClient
  } else {
    if (!resolver) throw new Error("resolver is missing on the server")
    const enhancedResolver = (resolver.default as unknown) as EnhancedResolver<TInput, TResult>
    enhancedResolver.middleware = resolver.middleware
    enhancedResolver._meta = {
      name: resolverName,
      type: resolverType,
      filePath: resolverFilePath,
      apiUrl: apiUrl,
    }
    return enhancedResolver
  }
}

型定義のオーバーロード部分はさておき、ターゲットが client か server で分岐してます。

サーバーでの処理の方が簡単なので、見ていきましょう。元の関数参照を呼び出して、
middleware_meta を付与して関数そのものを返却します。

client の場合、 executeRpcCall(apiUrl, params, opts) でラップして、 executeRpcCall.warm(...) を実行します。

executeRpcCall を見てみましょう。

// packages/core/src/rpc.ts

export const executeRpcCall = <TInput, TResult>(
  apiUrl: string,
  params: TInput,
  opts: RpcOptions = {},
) => {
  if (!opts.fromQueryHook && !opts.fromInvoke) {
    console.warn(
      "[Deprecation] Directly calling queries/mutations is deprecated in favor of invoke(queryFn, params)",
    )
  }

  if (isServer) return (Promise.resolve() as unknown) as CancellablePromise<TResult>
  clientDebug("Starting request for", apiUrl)

  const headers: Record<string, any> = {
    "Content-Type": "application/json",
  }

  const antiCSRFToken = getAntiCSRFToken()
  if (antiCSRFToken) {
    clientDebug("Adding antiCSRFToken cookie header", antiCSRFToken)
    headers[HEADER_CSRF] = antiCSRFToken
  } else {
    clientDebug("No antiCSRFToken cookie found")
  }

  let serialized: SuperJSONResult
  if (opts.alreadySerialized) {
    // params is already serialized with superjson when it gets here
    // We have to serialize the params before passing to react-query in the query key
    // because otherwise react-query will use JSON.parse(JSON.stringify)
    // so by the time the arguments come here the real JS objects are lost
    serialized = (params as unknown) as SuperJSONResult
  } else {
    serialized = serialize(params)
  }

  // Create a new AbortController instance for this request
  const controller = new AbortController()

  const promise = window
    .fetch(apiUrl, {
      method: "POST",
      headers,
      credentials: "include",
      redirect: "follow",
      body: JSON.stringify({
        params: serialized.json,
        meta: {
          params: serialized.meta,
        },
      }),
      signal: controller.signal,
    })
    .then(async (result) => {
      clientDebug("Received request for", apiUrl)
      if (result.headers) {
        if (result.headers.get(HEADER_PUBLIC_DATA_TOKEN)) {
          publicDataStore.updateState()
          clientDebug("Public data updated")
        }
        if (result.headers.get(HEADER_SESSION_REVOKED)) {
          clientDebug("Sessin revoked")
          publicDataStore.clear()
        }
        if (result.headers.get(HEADER_CSRF_ERROR)) {
          const err = new CSRFTokenMismatchError()
          delete err.stack
          throw err
        }
      }

      let payload
      try {
        payload = await result.json()
      } catch (error) {
        throw new Error(`Failed to parse json from request to ${apiUrl}`)
      }

      if (payload.error) {
        let error = deserializeError(payload.error) as any
        // We don't clear the publicDataStore for anonymous users
        if (error.name === "AuthenticationError" && publicDataStore.getData().userId) {
          publicDataStore.clear()
        }

        const prismaError = error.message.match(/invalid.*prisma.*invocation/i)
        if (prismaError && !("code" in error)) {
          error = new Error(prismaError[0])
          error.statusCode = 500
        }

        // Prevent client-side error popop from showing
        delete error.stack

        throw error
      } else {
        const data =
          payload.result === undefined
            ? undefined
            : deserialize({json: payload.result, meta: payload.meta?.result})

        if (!opts.fromQueryHook) {
          const queryKey = getQueryKeyFromUrlAndParams(apiUrl, params)
          queryCache.setQueryData(queryKey, data)
        }
        return data as TResult
      }
    }) as CancellablePromise<TResult>

  // Disable react-query request cancellation for now
  // Having too many weird bugs with it enabled
  // promise.cancel = () => controller.abort()

  return promise
}

executeRpcCall.warm = (apiUrl: string) => {
  if (isClient) {
    return window.fetch(apiUrl, {method: "HEAD"})
  } else {
    return
  }
}

実際の流れ

  • */queries/xxx をクライアントとサーバー用に、別々にビルドする
  • クライアント実行時は isomorhic client を通して、 POST /api/*/queries/xxx を送信
  • pages/api/*/queries/xxx が応答し、JSON を返却する
  • …となることを、blitzは最初から知っていたかのように TypeScript で型付けして呼んでいるように見せている

ということを踏まえると、公式ドキュメントの次の仕様が理解しやすいと思います。

RPC Specification | Blitz.js ⚡️

まとめ

  • 特定のディレクトリに置かれたファイルに対して、 isomorphism でクラサバを同じように見せることで、関数の呼び出しを同じAPIに見せている。
  • サーバー側のリソースは null-loader で参照できないように潰されているので、セキュリティリスクは(ここでは)発生しない

個人的な課題だった、blitz のセキュリティ上の懸念は払拭されました。
自分で似たようなフレームワークを作りたくなった時、発想は流用できるような気がします。