blitz-js がどうやってサーバー上の関数のクライアントでの呼び出しを実現しているのか、調査した
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/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 判定された実装は、 _resolvers
、 apiHandler
, 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
でラップします。
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
}
}
- warm で HEAD だけ送信
- serverless だとコールドスタートになることがあるので、HEADだけ送って叩き起こす
- antiCSRFToken を付与
- superjson で parse
- blitz-js/superjson: Safely serialize JavaScript expressions to a superset of JSON, which includes Dates, BigInts, and more.
- 要は Map や Set も送信できるように拡張された JSON parser
- fetch の中断用に AbortController を用意
- window.fetch を実行
実際の流れ
-
*/queries/xxx
をクライアントとサーバー用に、別々にビルドする - クライアント実行時は isomorhic client を通して、
POST /api/*/queries/xxx
を送信 -
pages/api/*/queries/xxx
が応答し、JSON を返却する - …となることを、blitzは最初から知っていたかのように TypeScript で型付けして呼んでいるように見せている
ということを踏まえると、公式ドキュメントの次の仕様が理解しやすいと思います。
RPC Specification | Blitz.js ⚡️
まとめ
- 特定のディレクトリに置かれたファイルに対して、 isomorphism でクラサバを同じように見せることで、関数の呼び出しを同じAPIに見せている。
- サーバー側のリソースは null-loader で参照できないように潰されているので、セキュリティリスクは(ここでは)発生しない
個人的な課題だった、blitz のセキュリティ上の懸念は払拭されました。
自分で似たようなフレームワークを作りたくなった時、発想は流用できるような気がします。
Discussion