はじめに
お疲れ様です。ふぁるです。
今回はRemix3についての記事です。
Remix3はReactを使わなくなりました。
フロントエンドの開発において、インパクトとして大きいと思っています。
個人的に、ここ数年、LLMの進歩にばかり技術情報が突出し、活発であったフロントエンドにおけるリリースニュースが少なくなり、退屈を感じていた側面もありました。
Webから拾える範疇ですが、Remix3のコアの思想、内部の実装の解説、Remix3を用いての実装例を本記事で扱います。
長編記事ですので、ぜひいいね、Stockいただき、時間をかけて読んでいただけると幸いです。
Remix3はまだ発表されたばかりで、プレビュー版ですらないv0.xのアルファ/ベータ版相当の状況です。
今後、設計やAPIが変更される可能性が高く、あくまで現状のRemix3の背景・設計思想・リポジトリの解説記事であることをご承知ください。
Remix3 is 何
このセクションは以下記事を参考にさせていただきました。
2025年10月10日、Remix Jam 2025にて、@mjackson、@ryanflorenceより発表されたRemixのメジャーバージョンです。
Remix Jam 2025とは
ウェブフレームワーク Remix の「過去・現在・未来」をテーマにしたカンファレンスです。
カナダ・トロントのShopifyのオフィスが併設された会場で開催されました。
"現代のWebフレームワーク全般の課題"に対して設計されたフレームワークとなります。
READMEに記されている設計原則が以下です。
- Model-First Development(モデル優先の開発)
- Build on Web APIs(Web API上に構築)
- Religiously Runtime(徹底したランタイム重視)
- Avoid Dependencies(依存関係の回避)
- Demand Composition(組み立て可能性の要求)
- Distribute Cohesively(一貫した配布)
各原則の詳細は後のセクションで紹介しますが、全体の課題感として 「現代のWebフレームワークは複雑になりすぎた」 という認識があります。
シンプルで、Web標準に忠実で、AI時代に最適化されたフレームワーク」 と捉えていただくと、以降のセクションを読み取りやすいかと思います。
2025/10/19時点のREADME原文 設計原則部分1. **Model-First Development**. AI fundamentally shifts the human-computer interaction model for both user experience and developer workflows. Optimize the source code, documentation, tooling, and abstractions for LLMs. Additionally, develop abstractions for applications to use models in the product itself, not just as a tool to develop it. 2. **Build on Web APIs**. Sharing abstractions across the stack greatly reduces the amount of context switching, both for humans and machines. Build on the foundation of Web APIs and JavaScript because it is the only full stack ecosystem. 3. **Religiously Runtime**. Designing for bundlers/compilers/typegen (and any pre-runtime static analysis) leads to poor API design that eventually pollutes the entire system. All packages must be designed with no expectation of static analysis and all tests must run without bundling. Because browsers are involved, `--import` loaders for simple transformations like TypeScript and JSX are permissible. 4. **Avoid Dependencies**. Dependencies lock you into somebody else's roadmap. Choose them wisely, wrap them completely, and expect to replace most of them with our own package eventually. The goal is zero. 5. **Demand Composition**. Abstractions should be single-purpose and replaceable. A composable abstraction is easy to add and remove from an existing program. Every package must be useful and documented independent of any other context. New features should first be attempted as a new package. If impossible, attempt to break up the existing package to make it more composable. However, tightly coupled modules that almost always change together in both directions should be moved to the same package. 6. **Distribute Cohesively**. Extremely composable ecosystems are difficult to learn and use. Remix will be distributed as a single `remix` package for both distribution and documentation.
1. Model-First Development(モデル優先の開発)
背景にある課題
-
AI/LLMが既存フレームワークのコードを理解・生成しづらい
- 複雑な設定ファイル、独自のDSL、暗黙的な規約が多い
- フレームワーク固有の概念をLLMに説明するのが困難
-
開発者がAIツールを効果的に使えない
- コード生成時にフレームワークの知識が必要
- AIが生成したコードが動かないことが多い
-
アプリケーション自体にAI機能を組み込みづらい
- 開発ツールとしてのAI利用だけでなく、プロダクト機能としてのAI統合が難しい
課題の具体例:Next.js App Router
// app/users/[id]/page.tsx
// ❌ LLMが理解しづらい暗黙的な規約
export default async function UserPage({ params }: { params: { id: string } }) {
// - ファイル名がルートになる(暗黙的)
// - Server ComponentとClient Componentの境界が不明確
// - 'use client'ディレクティブの意味をLLMが理解する必要がある
// - これはサーバーで実行される?クライアントで実行される?
const user = await fetchUser(params.id)
return <div>{user.name}</div>
}
// next.config.js
// ❌ 複雑な設定ファイル
module.exports = {
experimental: {
serverActions: true,
serverComponentsExternalPackages: ['some-package'],
},
webpack: (config) => {
// カスタムWebpack設定...
},
}
問題点:
- ファイル名とディレクトリ構造に意味がある(暗黙的)
- Server/Client Componentの動作がディレクティブに依存
- LLMがこの暗黙的なルールを全て理解する必要がある
解決策の具体例(demosから引用)
明示的なルート定義
// demos/bookstore/routes.ts
import { route, formAction, resources } from '@remix-run/fetch-router'
export let routes = route({
// Simple static routes
home: '/',
about: '/about',
// フォームアクション(GET + POST)
contact: formAction('/contact'),
// ネストされたリソース
account: route('/account', {
index: '/',
settings: formAction('settings', {
formMethod: 'PUT',
names: { action: 'update' },
}),
orders: resources('orders', {
only: ['index', 'show'],
param: 'orderId',
}),
}),
// 管理画面
admin: route('/admin', {
books: resources('books', { param: 'bookId' }),
users: resources('users', {
only: ['index', 'show', 'edit', 'update', 'destroy'],
param: 'userId',
}),
}),
})
明示的なハンドラー実装
// demos/bookstore/app/account.tsx
import type { RouteHandlers } from '@remix-run/fetch-router'
import { redirect } from '@remix-run/fetch-router'
export default {
use: [requireAuth], // ミドルウェアを明示的に指定
handlers: {
index() {
let user = getCurrentUser()
return render(
<Layout>
<h1>My Account</h1>
<div class="card">
<p><strong>Name:</strong> {user.name}</p>
<p><strong>Email:</strong> {user.email}</p>
</div>
</Layout>,
)
},
settings: {
async update({ formData }) {
let user = getCurrentUser()
let name = formData.get('name')?.toString() ?? ''
let email = formData.get('email')?.toString() ?? ''
updateUser(user.id, { name, email })
return redirect(routes.account.index)
},
},
orders: {
show({ params }) {
let order = getOrderById(params.orderId)
if (!order) {
return render(<OrderNotFound />, { status: 404 })
}
return render(<OrderDetail order={order} />)
},
},
},
} satisfies RouteHandlers<typeof routes.account>
利点:
- ルートが明示的に定義されている
- Web標準のAPIのみを使用
- LLMが「これはサーバーサイドのハンドラー」と明確に理解できる
- 設定ファイルが不要
2. Build on Web APIs(Web API上に構築)
背景にある課題
-
フレームワークごとの独自抽象化による学習コストの増大
- Express、Koa、Fastifyなど、それぞれ異なるリクエスト/レスポンスオブジェクト
- フレームワークを学ぶ = 独自APIを覚える作業になっている
-
サーバーとクライアントでのコンテキストスイッチ
- サーバー:Node.js Stream、Buffer
- ブラウザ:Web Streams、Uint8Array
- 同じことをするのに違うAPIを使う必要がある
-
特定のランタイムへのロックイン
- Node.js固有のAPIに依存すると、Deno、Bun、Cloudflare Workersで動かない
課題の具体例:Express(Node.js固有のAPI)
// ❌ Node.js固有の抽象化
import express from 'express'
import { Readable } from 'node:stream'
app.post('/upload', (req, res) => {
// req, res はNode.js固有のオブジェクト
// ストリーム処理もNode.js Stream API
req.pipe(someTransform).pipe(res)
// Bufferを使用
let chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
req.on('end', () => {
let body = Buffer.concat(chunks).toString()
res.json({ message: 'OK' })
})
})
問題点:
-
req、resはNode.js固有 -
Stream、Bufferはブラウザでは使えない - Cloudflare WorkersやDenoでは動かない
- フロントエンドエンジニアが知らないAPIを学ぶ必要がある
解決策の具体例(demosから引用)
サーバー起動(Web標準のRequest/Responseを使用)
// demos/bookstore/server.ts
import * as http from 'node:http'
import { createRequestListener } from '@remix-run/node-fetch-server'
import { router } from './app/router.ts'
// createRequestListenerがNode.jsのHTTPをWeb標準に変換
let server = http.createServer(
createRequestListener(async (request) => {
try {
// ここで受け取るrequestは標準のRequestオブジェクト
return await router.fetch(request)
} catch (error) {
console.error(error)
// 標準のResponseオブジェクトを返す
return new Response('Internal Server Error', { status: 500 })
}
}),
)
server.listen(44100, () => {
console.log(`Bookstore is running on http://localhost:44100`)
})
ルーターの設定(Web標準APIのみ使用)
// demos/bookstore/app/router.ts
import { createRouter } from '@remix-run/fetch-router'
import { logger } from '@remix-run/fetch-router/logger-middleware'
import { routes } from '../routes.ts'
export let router = createRouter({ uploadHandler })
// グローバルミドルウェア
router.use(storeContext)
if (process.env.NODE_ENV === 'development') {
router.use(logger())
}
// ルートマッピング
router.get(routes.assets, publicHandlers.assets)
router.get(routes.images, publicHandlers.images)
router.map(routes.home, marketingHandlers.home)
router.map(routes.books, booksHandlers)
router.map(routes.auth, authHandlers)
router.map(routes.account, accountHandlers)
ハンドラーの実装(Web標準のAPIのみ)
// demos/bookstore/app/books.tsx
import type { RouteHandlers } from '@remix-run/fetch-router'
import { json } from '@remix-run/fetch-router'
export default {
index() {
let books = getAllBooks()
// Web標準のResponseを返す
return json({ books })
},
show({ params }) {
let book = getBookBySlug(params.slug)
if (!book) {
// Web標準のResponseで404を返す
return json({ error: 'Not found' }, { status: 404 })
}
return json({ book })
},
} satisfies RouteHandlers<typeof routes.books>
利点:
- ブラウザと全く同じAPI(Request/Response/FormData)
- Deno、Bun、Cloudflare Workersでそのまま動く
- フロントエンドの知識がそのまま使える
- Node.js固有のAPIを覚える必要がない
3. Religiously Runtime(徹底したランタイム重視)
背景にある課題
-
ビルドツールへの過度な依存
- バンドラー、コンパイラ、型生成が必須になっている
- 開発環境のセットアップが複雑
- ビルドが遅い、トラブルシューティングが困難
-
静的解析前提の設計による制約
- 「ビルド時にこれを解析する」前提でAPIが設計される
- ランタイムでのシンプルな動作が犠牲になる
- 例:ファイルベースルーティングは静的解析に依存
-
システム全体の複雑化
- ビルドツールの設定が肥大化
- プラグインの互換性問題
- 「ビルドは通るが動かない」問題
課題の具体例:従来のRemix v2のローダー
// app/routes/users.$id.tsx
// ❌ ファイルベースルーティング = ビルド時の静的解析が必要
// この関数がビルド時に特別扱いされる
export async function loader({ params }: LoaderFunctionArgs) {
let user = await db.users.findById(params.id)
return json({ user })
}
export default function UserPage() {
// useLoaderDataもビルド時に型が生成される
let { user } = useLoaderData<typeof loader>()
return <div>{user.name}</div>
}
// remix.config.js
// ❌ 複雑なビルド設定
module.exports = {
serverBuildPath: "build/index.js",
publicPath: "/build/",
serverModuleFormat: "cjs",
future: {
v3_routeConvention: true,
},
}
問題点:
- ファイル名とディレクトリがルートを決定(静的解析必須)
-
loaderが特殊な関数として扱われる - ビルドステップなしでは動作しない
- テストを書く際もビルドが必要
課題の具体例:Next.jsのgetServerSideProps
// pages/users/[id].tsx
// ❌ ビルド時に特別扱いされる関数
export async function getServerSideProps(context: GetServerSidePropsContext) {
// この関数名が予約されている
let user = await fetchUser(context.params?.id as string)
return { props: { user } }
}
export default function UserPage({ user }: { user: User }) {
return <div>{user.name}</div>
}
問題点:
-
getServerSidePropsという名前が予約語 - Next.jsのビルドシステムが関数を特別扱い
- 単純にNode.jsで実行できない
解決策の具体例(demosから引用)
ミドルウェアの実装
// demos/bookstore/app/middleware/auth.ts
import type { Middleware } from '@remix-run/fetch-router'
import { redirect } from '@remix-run/fetch-router'
import { routes } from '../../routes.ts'
/**
* ログイン必須のミドルウェア
* 認証されていなければログインページにリダイレクト
*/
export let requireAuth: Middleware = async ({ request, storage }) => {
let session = getSession(request)
let userId = getUserIdFromSession(session.sessionId)
if (!userId) {
return redirect(routes.auth.login.index, 302)
}
let user = getUserById(userId)
if (!user) {
return redirect(routes.auth.login.index, 302)
}
// コンテキストにユーザー情報を追加
storage.set(USER_KEY, user)
storage.set(SESSION_ID_KEY, session.sessionId)
}
実際の使用例(認証が必要なルート)
// demos/bookstore/app/account.tsx
export default {
// ミドルウェアを適用
use: [requireAuth],
handlers: {
index() {
let user = getCurrentUser()
return render(
<Layout>
<h1>My Account</h1>
<p><strong>Name:</strong> {user.name}</p>
</Layout>,
)
},
settings: {
async update({ formData }) {
let user = getCurrentUser()
let name = formData.get('name')?.toString() ?? ''
let email = formData.get('email')?.toString() ?? ''
updateUser(user.id, { name, email })
return redirect(routes.account.index)
},
},
},
} satisfies RouteHandlers<typeof routes.account>
テストの実例
// demos/bookstore/app/account.test.ts
import { test } from 'node:test'
import * as assert from 'node:assert/strict'
import { router } from './router.ts'
test('redirects to login when not authenticated', async () => {
let response = await router.fetch('http://localhost/account')
assert.equal(response.status, 302)
assert.equal(response.headers.get('Location'), '/login')
})
// バンドル不要、node --test で直接実行可能
利点:
- ビルドステップ不要で開発・テストが可能
- 通常の関数なので理解しやすい
- デバッグが容易
- 標準の
fetch()でテスト可能
4. Avoid Dependencies(依存関係の回避)
背景にある課題
-
サードパーティライブラリのロードマップに縛られる
- ライブラリがメンテナンスされなくなるリスク
- 破壊的変更への対応コスト
- セキュリティパッチの遅延
-
依存の依存が膨大になる問題
-
node_modulesが巨大化 - セキュリティ脆弱性の監視範囲が広がる
- ビルド時間の増加
-
-
依存ライブラリ間の非互換性
- バージョン競合
- peer dependencies地獄
- アップデートの連鎖的な影響
課題の具体例:一般的なExpressアプリ
// ❌ 依存関係が多い典型的なExpressアプリ
{
"dependencies": {
"express": "^4.18.0",
"body-parser": "^1.20.0", // リクエストボディパース
"cookie-parser": "^1.4.6", // Cookie処理
"multer": "^1.4.5", // ファイルアップロード
"express-validator": "^7.0.1", // バリデーション
"helmet": "^7.0.0", // セキュリティ
"cors": "^2.8.5", // CORS
"compression": "^1.7.4", // 圧縮
"morgan": "^1.10.0", // ロギング
// ... その他多数
}
}
// npm install の結果
// node_modules に 500+ のパッケージがインストールされる
問題点:
- 直接的な依存は10個程度でも、間接的な依存で500個以上に
- 各パッケージのセキュリティ脆弱性を監視する必要がある
- 一つのパッケージの更新が連鎖的に影響
課題の具体例:依存関係の脆弱性
# ❌ よくある npm audit の結果
$ npm audit
found 15 vulnerabilities (3 moderate, 12 high)
run `npm audit fix` to fix them, or `npm audit` for details
# 依存の依存の依存に脆弱性があり、修正できないケースも...
解決策の具体例(実際のpackage.jsonから引用)
fetch-router: ワークスペース内のパッケージのみ依存
// packages/fetch-router/package.json
{
"dependencies": {
"@remix-run/form-data-parser": "workspace:*",
"@remix-run/route-pattern": "workspace:*",
"@remix-run/headers": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.6.0",
"esbuild": "^0.25.10"
}
}
route-pattern: 完全にゼロ依存
// packages/route-pattern/package.json
{
"dependencies": {},
"devDependencies": {
"@ark/attest": "^0.49.0",
"@types/node": "^24.6.0",
"esbuild": "^0.25.10"
}
}
multipart-parser: ゼロ依存
// packages/multipart-parser/package.json
{
"dependencies": {},
"devDependencies": {
"@types/node": "^24.6.0",
"esbuild": "^0.25.10"
}
}
multipart-parserのREADMEより:
🚀 Why multipart-parser?
- Universal JavaScript - One library that works everywhere
- Blazing Fast - Consistently outperforms popular alternatives like busboy
- Zero Dependencies - Lightweight and secure with no external dependencies
- Memory Efficient - Streaming architecture
- Type Safe - Written in TypeScript
- Standards Based - Built on the web standard Streams API
利点:
-
node_modulesが極小 - セキュリティリスクが最小限
- メンテナンスが容易
- ライブラリのロードマップに縛られない
5. Demand Composition(組み立て可能性の要求)
背景にある課題
-
モノリシックなフレームワークの柔軟性の欠如
- 全部入りパッケージで、一部だけ使いたい時に困る
- 不要な機能もバンドルに含まれる
- フレームワークの一部を置き換えられない
-
密結合による保守性の低下
- 機能同士が絡み合っていて、変更が困難
- テストが書きづらい
- 特定の機能だけアップデートできない
-
再利用性の低さ
- フレームワークの外で使えない
- 他のプロジェクトに移植できない
- 学習が他に活かせない
課題の具体例:Next.jsの密結合
// ❌ Next.jsのルーティングは単独では使えない
// Next.jsプロジェクト全体が必要
// フレームワーク全体に依存
import { useRouter } from 'next/router'
import { GetServerSideProps } from 'next'
// これらの機能を「ルーティングだけ」使うことはできない
// 必ずNext.jsプロジェクト全体が必要
問題点:
- ルーティングだけ使いたくても、Next.js全体が必要
- 他のプロジェクトでNext.jsのルーターだけ使うことは不可能
- 学習したことが他のフレームワークで活かせない
課題の具体例:Expressのミドルウェアvsフレームワーク
// ❌ フレームワークと密結合
import express from 'express'
import bodyParser from 'body-parser'
let app = express()
app.use(bodyParser.json()) // Express専用
問題点:
- body-parserはExpressに依存
- Expressの外では使えない
- 他のフレームワークに移植できない
解決策の具体例
demosでの実際の使用例
// demos/bookstore/app/books.tsx
import type { RouteHandlers } from '@remix-run/fetch-router'
import { routes } from '../routes.ts'
export default {
use: [loadAuth],
handlers: {
index() {
let books = getAllBooks()
let genres = getAvailableGenres()
return render(
<Layout>
<h1>Browse Books</h1>
{/* 型安全なURL生成 */}
<form action={routes.search.href()} method="GET">
<input type="search" name="q" placeholder="Search..." />
<button type="submit">Search</button>
</form>
<div>
{genres.map((genre) => (
{/* params.genreの型が自動推論される */}
<a href={routes.books.genre.href({ genre })}>
{genre}
</a>
))}
</div>
<div class="grid">
{books.map((book) => (
<Frame src={routes.fragments.bookCard.href({ slug: book.slug })} />
))}
</div>
</Layout>,
)
},
genre({ params }) {
// params.genre は string 型として推論される
let books = getBooksByGenre(params.genre)
return render(<BookList books={books} />)
},
show({ params }) {
// params.slug は string 型として推論される
let book = getBookBySlug(params.slug)
if (!book) {
return render(<NotFound />, { status: 404 })
}
return render(<BookDetail book={book} />)
},
},
} satisfies RouteHandlers<typeof routes.books>
route-pattern を単独で使用(他のプロジェクトでも)
// route-pattern README Example
import { RoutePattern } from '@remix-run/route-pattern'
let pattern = new RoutePattern('blog/:slug')
// URLマッチング
pattern.match('https://remix.run/blog/remixing-shopify')
// { params: { slug: 'remixing-shopify' } }
pattern.match('https://remix.run/admin')
// null
// URL生成
pattern.href({ slug: 'remixing-shopify' })
// "/blog/remixing-shopify"
複雑なパターンマッチング
// route-pattern README Example
let pattern = new RoutePattern('api(/v:major(.:minor))/customers/:id(.:format)')
pattern.match('https://shopify.com/api/customers/cari')
// { params: { major: undefined, minor: undefined, id: 'cari', format: undefined } }
pattern.match('https://shopify.com/api/v2.1/customers/cari.json')
// { params: { major: '2', minor: '1', id: 'cari', format: 'json' } }
pattern.href({ major: '2', minor: '1', id: 'cari', format: 'json' })
// "/api/v2.1/customers/cari.json"
pattern.href({ id: 'cari' })
// "/api/customers/cari"
headers を単独で使用
// headers README Example
import Headers from '@remix-run/headers'
let headers = new Headers()
// Accept
headers.accept = 'text/html, text/*;q=0.9'
headers.accept.accepts('text/html') // true
headers.accept.accepts('text/plain') // true
headers.accept.accepts('image/jpeg') // false
// Content-Type
headers.contentType = 'application/json; charset=utf-8'
headers.contentType.mediaType // "application/json"
headers.contentType.charset // "utf-8"
// Set-Cookie
headers.setCookie[0].name // 'session_id'
headers.setCookie[0].maxAge = 3600
headers.setCookie[0].secure = true
multipart-parser を単独で使用(Cloudflare Workers)
// multipart-parser README Example
import { parseMultipartRequest } from '@remix-run/multipart-parser'
async function handleRequest(request: Request): void {
for await (let part of parseMultipartRequest(request)) {
if (part.isFile) {
console.log(`File: ${part.filename} (${part.size} bytes)`)
console.log(`Content type: ${part.mediaType}`)
// Upload to R2, S3, etc.
await saveFile(part.filename, part.bytes)
} else {
console.log(`Field: ${part.name} = ${part.text}`)
}
}
}
利点:
- 必要なパッケージだけインストール
- 学習も段階的に可能
- 他のフレームワークでも使える
- 部分的に置き換え可能
6. Distribute Cohesively(一貫した配布)
背景にある課題
-
極度に細分化されたエコシステムの学習困難
- どのパッケージを使えばいいか分からない
- パッケージの組み合わせ方が不明確
- ドキュメントが分散していて探しづらい
-
初心者の参入障壁
- 始めるまでに調査が必要
- ベストプラクティスが分散
- サンプルコードが動かない
-
バージョン管理の複雑さ
- パッケージ間の互換性を自分で管理
- アップデート時の組み合わせテストが必要
課題の具体例:React エコシステムの複雑さ
# ❌ Reactアプリを始めるための意思決定が多すぎる
# ルーティング: どれを選ぶ?
npm install react-router-dom
# or
npm install @tanstack/react-router
# or
npm install wouter
# 状態管理: どれを選ぶ?
npm install redux react-redux @reduxjs/toolkit
# or
npm install zustand
# or
npm install jotai
# or
npm install recoil
# データフェッチング: どれを選ぶ?
npm install @tanstack/react-query
# or
npm install swr
# or
npm install apollo-client
# フォーム: どれを選ぶ?
npm install react-hook-form
# or
npm install formik
# CSS: どれを選ぶ?
# styled-components / emotion / CSS Modules / Tailwind / ...
# 結果:選択肢が多すぎて迷う、組み合わせの検証が必要
問題点:
- 学習コストが高い(各ライブラリの調査が必要)
- 組み合わせによっては相性問題が発生
- ドキュメントが分散していて全体像が見えない
- 「公式の」ベストプラクティスがない
課題の具体例:バージョン管理の複雑さ
// ❌ パッケージ間の互換性を自分で管理
{
"dependencies": {
"react": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-query": "^3.39.0", // v3系
"@tanstack/react-query": "^5.0.0" // v5系?どっち?
}
}
// パッケージをアップデートする際:
// - react-router-dom v6.20 は react 18 に対応してる?
// - react-query v3 と v5 の違いは?
// - 全部アップデートして大丈夫?
問題点:
- 互換性の調査が必要
- アップデート時のリスクが高い
- 依存関係の組み合わせ爆発
解決策(READMEから引用)
README.mdより:
Goals
Although we recommend the
remixpackage for ease of use, all packages that make up Remix should be usable standalone as well. This forces us to consider package boundaries and helps us define public interfaces that are portable and interopable.
6. Distribute Cohesively. Extremely composable ecosystems are difficult to learn and use. Remix will be distributed as a single
remixpackage for both distribution and documentation.
シンプルなインストール
# 一般的なケース
npm install remix
# 上級者や特殊なケース: 個別パッケージ
npm install @remix-run/route-pattern
npm install @remix-run/multipart-parser
統合されたAPI
// すべて一つのパッケージから
import {
createRouter,
route,
formAction,
resources,
json,
redirect,
html,
} from 'remix'
利点:
- 選択肢で迷わない
- 互換性が保証されている
- ドキュメントが統合されている
- アップデートが簡単
- でも、必要に応じて個別パッケージも使える
参考リンク
まとめ
Remix3の6つの設計原則は、相互に関連し合ってシンプルで強力なフレームワークを実現しています:
| 原則 | 解決する課題 | 得られる利点 |
|---|---|---|
| Model-First | AI/LLMでの扱いづらさ | 明示的で理解しやすいコード |
| Web APIs | 独自抽象化の乱立 | クロスプラットフォーム対応 |
| Runtime | ビルドツールへの依存 | シンプルな開発・テスト |
| Avoid Dependencies | 依存地獄 | 長期的な保守性 |
| Composition | 密結合 | 柔軟性と再利用性 |
| Cohesive Distribution | 学習の困難さ | 簡単な導入と一貫した体験 |
これらの原則により、Remix3は**「学びやすく、使いやすく、長く使える」**フレームワークを目指しています。
ここまでがRemix3の原型の思想の部分です。
Remix 3 エコシステムと実装フロー完全解説
以降のセクションでは、Remix 3のエコシステム全体と、HTTPリクエストがどのように処理されて最終的にレスポンスとして返されるかの実装フローを詳細に解説します。
エコシステムの全体像
Remix 3は、現状モノレポ構成で10個のパッケージから構成されています。これらのパッケージは、それぞれが独立して使用可能でありながら、組み合わせることでWebフレームワークとして機能します。
パッケージ一覧と役割
コアパッケージ群
-
@remix-run/fetch-router- Web Fetch APIベースのルーター -
@remix-run/route-pattern- 型安全なURLパターンマッチング -
@remix-run/node-fetch-server- Node.js用のFetch API変換レイヤー
データ処理パッケージ群
-
@remix-run/form-data-parser- ストリーミングformデータパーサー -
@remix-run/multipart-parser- 高速multipartパーサー -
@remix-run/file-storage- ファイルストレージ抽象化レイヤー
HTTPユーティリティ群
-
@remix-run/headers- 型安全なHTTPヘッダー操作 -
@remix-run/fetch-proxy- Fetch API ベースのプロキシ -
@remix-run/lazy-file- 遅延ロード可能なFileオブジェクト
特化型パッケージ
-
@remix-run/tar-parser- ストリーミングtarアーカイブパーサー
各パッケージの詳細解説
1. @remix-run/fetch-router
概要
Web標準のFetch APIをベースにした、最小限かつ組み合わせ可能なルーターです。Node.js、Bun、Deno、Cloudflare Workers、ブラウザなど、あらゆるJavaScript実行環境で動作します。
ディレクトリ構成
packages/fetch-router/
├── src/
│ ├── index.ts # メインエクスポート
│ ├── logger-middleware.ts # logger middlewareの便利エクスポート
│ └── lib/
│ ├── router.ts # Routerクラス - ルーティングのコア
│ ├── middleware.ts # Middleware型定義とrunMiddleware実装
│ ├── request-context.ts # RequestContextクラス - リクエスト情報の管理
│ ├── request-handler.ts # RequestHandler型定義
│ ├── request-methods.ts # HTTPメソッドの型定義
│ ├── app-storage.ts # AppStorage - ミドルウェア間のデータ共有
│ ├── route-map.ts # ルートマップの型定義とヘルパー
│ ├── route-handlers.ts # ルートハンドラーの型定義
│ ├── form-action.ts # フォームアクション用ヘルパー
│ ├── resource.ts # RESTfulリソースルーティング
│ ├── response-helpers.ts # レスポンス生成ヘルパー
│ ├── type-utils.ts # 型ユーティリティ
│ └── middleware/
│ └── logger.ts # logger middleware実装
├── README.md
└── package.json
主要機能
1. 型安全なルーティング
// 完全に型付けされたルート定義
let routes = route({
home: '/',
about: '/about',
blog: {
index: '/blog',
show: '/blog/:slug',
},
})
2. Koaスタイルのミドルウェア
ミドルウェアは「オニオンモデル」で実行されます。各ミドルウェアはnext()を呼び出すことで次のミドルウェアまたはハンドラーに制御を渡し、そのレスポンスを受け取ることができます。
// グローバルミドルウェア - すべてのルートで実行
router.use((context, next) => {
console.log(`${context.request.method} ${context.url.pathname}`)
return next()
})
// ルート固有のミドルウェア
router.get('/admin', [authenticate, authorize], () => new Response('Admin Dashboard'))
「オニオンモデル」について
Koaスタイルにおける、「入り」と「出」の両方でミドルウェアが処理を行う構造を指す。
リクエスト → MiddlewareA[前処理] → Handler → MiddlewareA[後処理] → レスポンス
この場合、middlewareの受け取るcontextは「リクエスト情報」を、nextは「次のミドルウェア、または最終的なハンドラー」を指す。
「Koaスタイル」について
2013年末にリリースされたNode.jsフレームワークが採用しているミドルウェアの設計パターン。
Expressのミドルウェアは線形モデルを採用しており、app.useされたミドルウェア内でnext()を実行した場合、以降の行は実行されない。
リクエスト → MiddlewareA[前処理] → Handlerといった処理順になる。
一方Koaではgenerator/yieldを使いミドルウェアを書くことが可能なため、ミドルウェアが双方向モデルとなる。
fetch-routerではKoaが採用されている。
"keywords": [
"fetch",
"router",
"routing",
"middleware",
"koa",
"url",
"pattern",
"match"
]
3. ネストされたルーター
大規模アプリケーションを構築する際、ルーターを階層化して整理できます。
let apiRouter = createRouter()
apiRouter.get('/users', () => new Response('Users'))
apiRouter.get('/posts', () => new Response('Posts'))
let mainRouter = createRouter()
mainRouter.mount('/api', apiRouter)
4. RESTfulリソースルーティング
Railsスタイルのリソースルーティングをサポートしています。
import { resources, createRoutes } from '@remix-run/fetch-router'
let routes = createRoutes({
books: resources('books'), // 7つのRESTfulアクション
})
router.map(routes.books, {
index() { return new Response('Books') },
create() { return new Response('Book Created', { status: 201 }) },
new() { return new Response('New Book') },
show({ params }) { return new Response(`Book ${params.id}`) },
edit({ params }) { return new Response(`Edit Book ${params.id}`) },
update({ params }) { return new Response(`Updated Book ${params.id}`) },
destroy({ params }) { return new Response(`Destroyed Book ${params.id}`) },
})
内部実装の重要なポイント
Router.fetch()メソッド
エントリーポイントとなるメソッドです。
async fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
let request = input instanceof Request ? input : new Request(input, init)
let response = await this.dispatch(request)
if (response == null) {
response = await this.#defaultHandler(new RequestContext(request))
}
return response
}
(ちなみに、#defaultHandlerのように#を接頭辞に付けているメンバーはクラスに対する private メンバーとなります。 Node14より正式サポートされています。)
Router.dispatch()メソッド
実際のルーティングロジックを実行します。
async dispatch(
request: Request | RequestContext,
// upstreamMiddleware: 親ルーターから引き継がれたミドルウェアの配列(マウント時のみ)
upstreamMiddleware?: Middleware[],
): Promise<Response | null> {
// === 1. RequestContextの準備 ===
// requestがRequestインスタンスの場合は#parseRequest()を呼び出してRequestContextに変換
// 既にRequestContextの場合はそのまま使用(サブルーターへの再帰呼び出し時)
// #parseRequest()はフォームデータのパース、メソッドオーバーライドの処理などを行う
let context = request instanceof Request ? await this.#parseRequest(request) : request
// === 2. マッチャーから全てのマッチを取得してイテレート ===
// this.#matcher.matchAll(context.url)は、登録されているすべてのルートパターンを順番に試行し、
// URLにマッチするものを優先度順(登録順)にイテレータとして返す
for (let match of this.#matcher.matchAll(context.url)) {
// === 3. サブルーターがマウントされている場合の処理 ===
// match.dataに'router'プロパティが存在する場合、このマッチはサブルーターへのマウントポイント
if ('router' in match.data) {
// マウントに必要な情報を取り出す
// - mountMiddleware: マウント時に指定されたミドルウェア
// - prefix: マウントされたパスのプレフィックス(例: '/api')
// - router: マウントされたサブルーターのインスタンス
let { middleware: mountMiddleware, prefix, router } = match.data
// === 3-1. URL変換: プレフィックスを除去 ===
// サブルーター内では相対パスで動作させるため、マウントプレフィックスを除去する
// 例: '/api/users' → '/users'(prefixが'/api'の場合)
let originalUrl = context.url // 元のURLを保存(後で復元するため)
let strippedUrl = new URL(match.url) // マッチしたURLをコピー
strippedUrl.pathname = strippedUrl.pathname.slice(prefix.length) // プレフィックスを削除
context.url = strippedUrl // コンテキストのURLを更新
// === 3-2. サブルーターへの再帰的なdispatch呼び出し ===
// サブルーターのdispatch()を呼び出し、処理を委譲する
// upstreamMiddleware(親のミドルウェア)とmountMiddleware(マウント時のミドルウェア)を連結して渡す
// これにより、親ルーター → マウントミドルウェア → サブルーター の順で実行される
let response = await router.dispatch(
context,
concatMiddleware(upstreamMiddleware, mountMiddleware),
)
// === 3-3. URLの復元 ===
// サブルーターの処理が終わったら、元のURLに戻す
// これにより、次のマッチを試行する際に正しいURLが使用される
context.url = originalUrl
// === 3-4. レスポンスの確認 ===
// サブルーターがレスポンスを返した場合(nullでない場合)、そのまま返す
if (response != null) {
return response
}
// サブルーターがnullを返した場合(マッチしなかった場合)、次のマッチを試行
continue
}
// === 4. 通常のルートマッチの処理 ===
// match.dataからルート情報を取り出す
// - method: HTTPメソッド('GET', 'POST', 'ANY'など)
// - routeMiddleware: このルートに登録されたミドルウェア
// - handler: このルートのハンドラー関数
let { method, middleware: routeMiddleware, handler } = match.data
// === 5. HTTPメソッドのチェック ===
// リクエストのHTTPメソッドがルートのメソッドと一致するかチェック
// - method === 'ANY': すべてのHTTPメソッドを受け入れる
// - method === context.method: メソッドが一致
// いずれでもない場合は、このルートをスキップして次のマッチを試行
if (method !== context.method && method !== 'ANY') {
continue
}
// === 6. コンテキストの更新 ===
// マッチしたルートのパラメータ(:idなど)とURLをコンテキストに設定
// これにより、ハンドラーやミドルウェアからcontext.paramsでパラメータにアクセスできる
context.params = match.params
context.url = match.url
// === 7. ミドルウェアの連結と実行 ===
// upstreamMiddleware(親ルーターから引き継がれたミドルウェア)と
// routeMiddleware(このルートに登録されたミドルウェア)を連結
let middleware = concatMiddleware(upstreamMiddleware, routeMiddleware)
// ミドルウェアが存在する場合はrunMiddleware()で実行し、
// 存在しない場合は直接ハンドラーを呼び出す
// runMiddleware()はオニオンモデルでミドルウェアを実行し、最後にハンドラーを呼び出す
return middleware != null
? await runMiddleware(middleware, context, handler)
: await handler(context)
}
// === 8. マッチしなかった場合 ===
// すべてのマッチを試行してもレスポンスが得られなかった場合、nullを返す
// fetch()メソッドはこの場合にdefaultHandlerを呼び出す
return null
}
2. @remix-run/route-pattern
概要
URL のパターンマッチングとURLの生成を、強力な型安全性とともに提供するライブラリです。
ディレクトリ構成
packages/route-pattern/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ ├── route-pattern.ts # RoutePatternクラス - パターンマッチングのコア
│ ├── parse.ts # パターン文字列のパーサー
│ ├── stringify.ts # パターンの文字列化
│ ├── href.ts # URL生成機能
│ ├── join.ts # パターンの結合
│ ├── params.ts # パラメータ型の推論
│ ├── matcher.ts # Matcherインターフェース
│ ├── regexp-matcher.ts # 正規表現ベースのマッチャー
│ ├── trie-matcher.ts # Trieベースのマッチャー(高速)
│ ├── search-constraints.ts # 検索パラメータ制約
│ ├── split.ts # 文字列分割ユーティリティ
│ ├── variant.ts # 型のバリアント生成
│ └── type-utils.ts # 型ユーティリティ
├── README.md
└── package.json
パターン構文
基本的なパス名マッチング
let pattern = new RoutePattern('blog/:slug')
pattern.match('https://remix.run/blog/remixing-shopify')
// { params: { slug: 'remixing-shopify' } }
pattern.href({ slug: 'remixing-shopify' })
// "/blog/remixing-shopify"
複数のパラメータを持つセグメント
let pattern = new RoutePattern('blog/:year-:month-:day/:slug(.html)')
pattern.match('https://remix.run/blog/2024-01-15/introducing-remix.html')
// { params: { year: '2024', month: '01', day: '15', slug: 'introducing-remix' } }
pattern.href({
year: '2024',
month: '01',
day: '15',
slug: 'introducing-remix',
})
// "/blog/2024-01-15/introducing-remix.html"
オプショナルセグメント
let pattern = new RoutePattern('api(/v:major(.:minor))/customers/:id(.:format)')
pattern.match('https://shopify.com/api/customers/cari')
// { params: { major: undefined, minor: undefined, id: 'cari', format: undefined } }
pattern.match('https://shopify.com/api/v2.1/customers/cari.json')
// { params: { major: '2', minor: '1', id: 'cari', format: 'json' } }
ワイルドカード
let pattern = new RoutePattern('http(s)://cdn.shopify.com/assets/*path.:ext')
pattern.match('https://cdn.shopify.com/assets/products/sneakers.webp')
// { params: { path: 'products/sneakers', ext: 'webp' } }
完全なURLマッチング
let pattern = new RoutePattern('https://:store.shopify.com/orders')
pattern.match('https://coffee-roasters.shopify.com/orders')
// { params: { store: 'coffee-roasters' } }
pattern.href({ store: 'remix' })
// "https://remix.shopify.com/orders"
検索パラメータマッチング
let pattern = new RoutePattern('search?q=routing')
pattern.match('https://remix.run/search?q=routing')
// match!
pattern.match('https://remix.run/search?q=other')
// null
内部実装
RoutePatternクラスは、パターンをコンパイルして正規表現に変換します。
function compilePattern(parsed: ParseResult, ignoreCase: boolean): CompileResult {
// パース結果からプロトコル、ホスト名、ポート、パス名を取り出す
let { protocol, hostname, port, pathname } = parsed
// === 1. オリジンマッチングの判定 ===
// hostnameが定義されている場合、プロトコル+ホスト名+ポート+パス名全体をマッチング対象とする
// 例: 'https://example.com:3000/users/:id' のようなパターン
// hostnameが未定義の場合、パス名のみをマッチング対象とする
// 例: '/users/:id' のようなパターン
let matchOrigin = hostname !== undefined
let matcher: RegExp
// パラメータ名を順番に格納する配列(キャプチャグループとパラメータ名の対応付けに使用)
let paramNames: string[] = []
// === 2. オリジンを含むパターンの場合 ===
if (matchOrigin) {
// === 2-1. プロトコル部分の正規表現を生成 ===
// protocolトークンが存在する場合、tokensToRegExpSourceでトークンを正規表現に変換
// 例: 'https' → 'https', ':protocol' → '(.*)' のようなキャプチャグループ
// protocolが未定義の場合、任意のプロトコルにマッチする '[^:]+' を使用
let protocolSource = protocol
? tokensToRegExpSource(protocol, '', '.*', paramNames, true)
: '[^:]+'
// === 2-2. ホスト名部分の正規表現を生成 ===
// hostnameトークンが存在する場合、tokensToRegExpSourceでトークンを正規表現に変換
// セパレータは '.'(ドット)、パラメータのデフォルトパターンは '[^.]+?'(ドット以外の1文字以上)
// 例: 'example.com' → 'example\\.com', ':subdomain.example.com' → '([^.]+?)\\.example\\.com'
// hostnameが未定義の場合、任意のホスト名にマッチする '[^/:]+'(スラッシュとコロン以外)
let hostnameSource = hostname
? tokensToRegExpSource(hostname, '.', '[^.]+?', paramNames, true)
: '[^/:]+'
// === 2-3. ポート部分の正規表現を生成 ===
// portが定義されている場合、`:${port}`の形式で固定ポートにマッチ
// 例: port='3000' → ':3000'
// portが未定義の場合、オプショナルなポート番号 '(?::[0-9]+)?' にマッチ
// 非キャプチャグループ(?:...)を使用し、ポートはパラメータとして扱わない
let portSource = port !== undefined ? `:${regexpEscape(port)}` : '(?::[0-9]+)?'
// === 2-4. パス名部分の正規表現を生成 ===
// pathnameトークンが存在する場合、tokensToRegExpSourceでトークンを正規表現に変換
// セパレータは '/'、パラメータのデフォルトパターンは '[^/]+?'(スラッシュ以外の1文字以上)
// 例: '/users/:id' → '/users/([^/]+?)'
// pathnameが未定義の場合、空文字列(ルートパスのみ)
let pathnameSource = pathname
? tokensToRegExpSource(pathname, '/', '[^/]+?', paramNames, ignoreCase)
: ''
// === 2-5. 全体の正規表現を構築 ===
// `^${protocol}://${hostname}${port}/${pathname}$` の形式で完全一致の正規表現を作成
// 例: 'https://example.com:3000/users/:id' →
// /^https:\/\/example\.com:3000\/users\/([^/]+?)$/
matcher = new RegExp(`^${protocolSource}://${hostnameSource}${portSource}/${pathnameSource}$`)
} else {
// === 3. パス名のみのパターンの場合 ===
// pathnameトークンが存在する場合、tokensToRegExpSourceでトークンを正規表現に変換
// セパレータは '/'、パラメータのデフォルトパターンは '[^/]+?'
let pathnameSource = pathname
? tokensToRegExpSource(pathname, '/', '[^/]+?', paramNames, ignoreCase)
: ''
// === 3-1. パス名のみの正規表現を構築 ===
// `^/${pathname}$` の形式で完全一致の正規表現を作成
// 例: '/users/:id' → /^\/users\/([^/]+?)$/
matcher = new RegExp(`^/${pathnameSource}$`)
}
// === 4. コンパイル結果を返す ===
// - matchOrigin: オリジンマッチングが必要かどうか
// - matcher: 生成された正規表現
// - paramNames: パラメータ名の配列(キャプチャグループの順序と対応)
return { matchOrigin, matcher, paramNames }
}
マッチング処理では、コンパイル済みの正規表現を使用してURLと照合します。
match(url: URL | string): RouteMatch<T> | null {
// === 1. URL文字列をURLオブジェクトに変換 ===
// 文字列が渡された場合、URLコンストラクタでパース
// 既にURLオブジェクトの場合はそのまま使用
if (typeof url === 'string') url = new URL(url)
// === 2. コンパイル済みの正規表現とメタ情報を取得 ===
// this.#compile()は初回呼び出し時にパターンをコンパイルし、結果をキャッシュ
// - matchOrigin: オリジン全体をマッチングするかどうか
// - matcher: コンパイル済みの正規表現
// - paramNames: パラメータ名の配列(キャプチャグループとの対応関係)
let { matchOrigin, matcher, paramNames } = this.#compile()
// === 3. 大文字小文字の無視処理 ===
// this.ignoreCaseがtrueの場合、pathnameを小文字に変換
// これにより、パターンとURLの大文字小文字を区別せずにマッチング
let pathname = this.ignoreCase ? url.pathname.toLowerCase() : url.pathname
// === 4. 正規表現によるマッチング実行 ===
// matchOriginがtrueの場合、`${origin}${pathname}`全体(例: 'https://example.com/users/123')
// matchOriginがfalseの場合、pathname のみ(例: '/users/123')をマッチング対象とする
// matcher.exec()はマッチ結果の配列を返す(マッチしない場合はnull)
// - match[0]: マッチした全体の文字列
// - match[1], match[2], ...: キャプチャグループ(パラメータ値)
let match = matcher.exec(matchOrigin ? `${url.origin}${pathname}` : pathname)
// マッチしなかった場合、nullを返す
if (match === null) return null
// === 5. キャプチャグループをパラメータ名にマッピング ===
// 正規表現のキャプチャグループ(match[1], match[2], ...)を
// paramNamesに登録されたパラメータ名に対応付けてオブジェクトに変換
// 例: paramNames=['id'], match[1]='123' → params={ id: '123' }
let params = {} as any
for (let i = 0; i < paramNames.length; i++) {
let paramName = paramNames[i]
// match[i + 1]でキャプチャグループを取得(match[0]は全体マッチなので+1)
params[paramName] = match[i + 1]
}
// === 6. 検索パラメータの制約チェック ===
// パターンに検索パラメータの制約が定義されている場合
// (例: '/users/:id?tab=profile&sort=name' のような制約)、
// url.search(クエリ文字列)が制約を満たすかチェック
if (
this.#parsed.searchConstraints != null &&
!matchSearch(url.search, this.#parsed.searchConstraints)
) {
// 制約を満たさない場合、マッチ失敗としてnullを返す
return null
}
// === 7. マッチ結果を返す ===
// マッチしたURLと抽出されたパラメータを含むオブジェクトを返す
return { url, params }
}
3. @remix-run/node-fetch-server
概要
Node.jsのHTTPサーバーをWeb標準のFetch APIで構築できるようにするパッケージです。Node.jsのhttp.IncomingMessageとhttp.ServerResponseを、Web標準のRequestとResponseに変換します。
ディレクトリ構成
packages/node-fetch-server/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ ├── request-listener.ts # createRequestListener - メイン機能
│ │ # createRequest - Node.js Request → Web Request変換
│ │ # sendResponse - Web Response → Node.js Response変換
│ ├── fetch-handler.ts # FetchHandler型定義
│ └── read-stream.ts # ストリーム読み取りユーティリティ
├── README.md
└── package.json
主要機能
基本的な使用方法
import * as http from 'node:http'
import { createRequestListener } from '@remix-run/node-fetch-server'
async function handler(request: Request) {
let url = new URL(request.url)
if (url.pathname === '/' && request.method === 'GET') {
return new Response('Welcome to the User API!')
}
return new Response('Not Found', { status: 404 })
}
let server = http.createServer(createRequestListener(handler))
server.listen(3000, () => {
console.log('Server running at http://localhost:3000')
})
ストリーミングレスポンス
async function handler(request: Request) {
if (request.url.endsWith('/stream')) {
let stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
controller.enqueue(new TextEncoder().encode(`Chunk ${i}\n`))
await new Promise((resolve) => setTimeout(resolve, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
}
return new Response('Not Found', { status: 404 })
}
カスタムホスト名設定
let hostname = process.env.HOST || 'api.example.com'
async function handler(request: Request) {
// request.url は設定したホスト名を使用
console.log(request.url) // https://api.example.com/path
return Response.json({
message: 'Hello from custom domain!',
url: request.url,
})
}
let server = http.createServer(createRequestListener(handler, { host: hostname }))
クライアント情報へのアクセス
import { type FetchHandler } from '@remix-run/node-fetch-server'
let handler: FetchHandler = async (request, client) => {
console.log(`Request from ${client.address}:${client.port}`)
if (isRateLimited(client.address)) {
return new Response('Too Many Requests', { status: 429 })
}
return Response.json({
message: 'Hello!',
yourIp: client.address,
})
}
内部実装
createRequest関数 - Node.js Request → Web Request変換
export function createRequest(
// req: Node.jsのIncomingMessage(HTTP/1.1)またはHttp2ServerRequest(HTTP/2)
req: http.IncomingMessage | http2.Http2ServerRequest,
// res: Node.jsのServerResponse - リクエストキャンセル検出のために使用
res: http.ServerResponse | http2.Http2ServerResponse,
// options: プロトコルやホストのオーバーライドオプション
options?: RequestOptions,
): Request {
// === 1. AbortControllerの作成 ===
// リクエストのキャンセルを管理するためのAbortControllerを作成
// Web標準のRequest.signalとしてこのcontrollerを使用
let controller: AbortController | null = new AbortController()
// === 2. レスポンスイベントへのリスナー登録 ===
// res.once('close'): クライアントが接続を閉じた時にAbortControllerをabort
// これによりリクエストがキャンセルされたことをアプリケーションに通知
res.once('close', () => controller?.abort())
// res.once('finish'): レスポンス送信完了時にcontrollerをクリーンアップ(メモリリーク防止)
res.once('finish', () => (controller = null))
// === 3. HTTPメソッドとヘッダーの取得 ===
// req.methodがundefinedの場合はGETをデフォルトとする
let method = req.method ?? 'GET'
// createHeaders()でNode.jsのヘッダーオブジェクトをWeb標準のHeadersに変換
let headers = createHeaders(req)
// === 4. プロトコルの判定 ===
// options.protocolが指定されていればそれを使用、
// 指定がなければreq.socketのencryptedプロパティでHTTPS判定
// - encrypted === true → 'https:'
// - encrypted === false → 'http:'
// デフォルトは'http:'
let protocol =
options?.protocol ?? ('encrypted' in req.socket && req.socket.encrypted ? 'https:' : 'http:')
// === 5. ホスト名の取得 ===
// 優先順位: options.host > Hostヘッダー > 'localhost'
// Hostヘッダーには通常ポート番号も含まれる(例: 'example.com:3000')
let host = options?.host ?? headers.get('Host') ?? 'localhost'
// === 6. 完全なURLの構築 ===
// req.url(相対パス: '/users/123?sort=name')と
// protocol + host('https://example.com')からURLオブジェクトを作成
// 結果: 'https://example.com/users/123?sort=name'
let url = new URL(req.url!, `${protocol}//${host}`)
// === 7. RequestInitオブジェクトの作成 ===
// Web標準のRequestコンストラクタに渡す初期化オプション
// - method: HTTPメソッド
// - headers: 変換済みのヘッダー
// - signal: AbortControllerのsignal(キャンセル通知用)
let init: RequestInit = { method, headers, signal: controller.signal }
// === 8. リクエストボディの処理(GETとHEAD以外) ===
// GETとHEADはボディを持たないため、それ以外のメソッドの場合にボディを追加
if (method !== 'GET' && method !== 'HEAD') {
// === 8-1. ReadableStreamの作成 ===
// Node.jsのストリーム(req)をWeb標準のReadableStreamに変換
init.body = new ReadableStream({
start(controller) {
// === 8-2. 'data'イベント: チャンクデータの受信 ===
// Node.jsのBufferをUint8Arrayに変換してReadableStreamに送る
// chunk.buffer: ArrayBufferへの参照
// chunk.byteOffset: バッファ内の開始位置
// chunk.byteLength: データの長さ
req.on('data', (chunk) => {
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
})
// === 8-3. 'end'イベント: データ受信完了 ===
// すべてのチャンクを受信したらストリームを閉じる
req.on('end', () => {
controller.close()
})
},
})
// === 8-4. duplex設定 ===
// Fetch仕様で要求される設定: リクエストボディを持つRequestには'duplex: half'が必要
// TypeScriptの型定義には含まれていないため、型アサーションを使用
;(init as { duplex: 'half' }).duplex = 'half'
}
// === 9. Web標準のRequestオブジェクトを返す ===
// URLとinitオプションからRequestインスタンスを作成
// このRequestオブジェクトがルーターやミドルウェアに渡される
return new Request(url, init)
}
sendResponse関数 - Web Response → Node.js Response変換
export async function sendResponse(
// res: Node.jsのServerResponse - クライアントにレスポンスを送信する対象
res: http.ServerResponse | http2.Http2ServerResponse,
// response: Web標準のResponse - ルーター/ミドルウェアから返されたレスポンス
response: Response,
): Promise<void> {
// === 1. ヘッダーの変換 ===
// Web標準のHeadersオブジェクトをNode.jsのヘッダー形式に変換
// 特にSet-Cookieなど、同じキー名で複数の値を持つヘッダーを正しく処理する必要がある
// Node.jsでは複数値ヘッダーを配列として表現: { 'set-cookie': ['cookie1=value1', 'cookie2=value2'] }
let headers: Record<string, string | string[]> = {}
for (let [key, value] of response.headers) {
// === 1-1. 既存のキーがある場合 ===
if (key in headers) {
// 既に配列の場合: 新しい値をpush
if (Array.isArray(headers[key])) {
headers[key].push(value)
} else {
// まだ配列でない場合(単一の値): 既存の値と新しい値で配列を作成
headers[key] = [headers[key] as string, value]
}
} else {
// === 1-2. 新しいキーの場合 ===
// 最初の値として単一文字列を設定
headers[key] = value
}
}
// === 2. ステータスコードとヘッダーの送信 ===
// HTTP/1.1とHTTP/2でwriteHeadの引数が異なるため、バージョンで分岐
if (res.req.httpVersionMajor === 1) {
// === 2-1. HTTP/1.1の場合 ===
// ステータスコード、ステータステキスト(例: 'OK', 'Not Found')、ヘッダーを送信
// HTTP/1.1ではステータステキストがサポートされている
res.writeHead(response.status, response.statusText, headers)
} else {
// === 2-2. HTTP/2の場合 ===
// HTTP/2ではステータステキストがサポートされていないため、ステータスコードとヘッダーのみ送信
// HTTP/2ではステータスは擬似ヘッダー ':status' として送信される
res.writeHead(response.status, headers)
}
// === 3. レスポンスボディの送信 ===
// response.bodyが存在し、かつリクエストメソッドがHEADでない場合にボディを送信
// HEADリクエストの場合、ヘッダーのみを返しボディは送信しない(HTTP仕様)
if (response.body != null && res.req.method !== 'HEAD') {
// === 3-1. ストリームからチャンクを読み取り ===
// readStream()でWeb標準のReadableStreamをAsyncIterableに変換
// for await...ofでチャンクを順次読み取り、Node.jsのレスポンスに書き込む
for await (let chunk of readStream(response.body)) {
// === 3-2. バックプレッシャーの処理 ===
// res.write()がfalseを返す場合、書き込みバッファが満杯
// この場合、'drain'イベントを待機してから次のチャンクを送信(バックプレッシャー制御)
// これによりメモリ使用量を抑え、大きなファイルでもストリーミング送信が可能
if (res.write(chunk) === false) {
await new Promise<void>((resolve) => {
// 'drain'イベント: 書き込みバッファが空になり、次のデータを受け入れ可能になった時に発火
res.once('drain', resolve)
})
}
}
}
// === 4. レスポンスの完了 ===
// res.end()でレスポンス送信を完了し、接続を閉じる(Keep-Aliveでない場合)
// これによりクライアントは完全なレスポンスを受信したことを認識できる
res.end()
}
4. ミドルウェアとリクエストコンテキスト
Middleware - オニオンモデルの実装
ミドルウェアは、先ほど解説したKoaと同様のオニオンモデルで実行されます。各ミドルウェアはnext()を呼び出すことで次のミドルウェアに制御を渡し、戻り値を受け取ることができます。
// === Middlewareインターフェース ===
// ミドルウェア関数の型定義
// - Method: HTTPメソッド('GET', 'POST'など)または'ANY'
// - Params: ルートパラメータの型(例: { id: string })
export interface Middleware<
Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY',
Params extends Record<string, any> = {},
> {
(
// context: リクエストコンテキスト(request, params, formDataなどを含む)
context: RequestContext<Method, Params>,
// next: 次のミドルウェアまたはハンドラーを呼び出す関数
next: NextFunction,
): Response | undefined | void | Promise<Response | undefined | void>
// 返り値:
// - Response: レスポンスを返してチェーンをショートサーキット
// ショートサーキット:以降のミドルウェアやハンドラーを実行せず、即座にレスポンスを返すこと
// - undefined/void: next()に制御を委譲
}
// === runMiddleware関数 - オニオンモデルの実装 ===
// ミドルウェア配列を順番に実行し、最後にハンドラーを呼び出す
// Koaスタイルのオニオンモデルを実装している
export function runMiddleware<
Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY',
Params extends Record<string, any> = {},
>(
// middleware: 実行するミドルウェアの配列(登録順)
middleware: Middleware<Method, Params>[],
// context: リクエストコンテキスト(全ミドルウェアで共有)
context: RequestContext<Method, Params>,
// handler: 最終的に呼び出されるルートハンドラー
handler: RequestHandler<Method, Params, Response>,
): Promise<Response> {
// === 1. next()の二重呼び出し検出用の変数 ===
// indexは現在実行中のミドルウェアのインデックスを保持
// -1で初期化し、各dispatch呼び出しで更新される
// もし i <= index なら、同じミドルウェア内でnext()が2回以上呼ばれたことを意味する
let index = -1
// === 2. dispatch関数 - ミドルウェアを再帰的に実行 ===
// i: 実行するミドルウェアのインデックス
let dispatch = async (i: number): Promise<Response> => {
// === 2-1. next()の二重呼び出しチェック ===
// 同じミドルウェア内でnext()が複数回呼ばれた場合はエラー
// これはKoaの仕様に準拠した動作で、ミドルウェアの誤用を防ぐ
if (i <= index) throw new Error('next() called multiple times')
// 現在のインデックスを更新
index = i
// === 2-2. 現在のミドルウェアまたはハンドラーを取得 ===
let fn = middleware[i]
// すべてのミドルウェアを実行し終えた場合(fn === undefined)、
// 最終的なハンドラーを呼び出してレスポンスを返す
if (!fn) return handler(context)
// === 2-3. next関数の作成 ===
// nextPromiseはnext()が呼ばれた時の結果を保持する変数
let nextPromise: Promise<Response> | undefined
// next関数: ミドルウェアがこれを呼び出すことで次のミドルウェアに制御を渡す
let next: NextFunction = (moreContext?: Partial<RequestContext>) => {
// === 2-3-1. コンテキストのマージ ===
// ミドルウェアがnext()にPartial<RequestContext>を渡した場合、
// 既存のcontextにマージする(例: next({ locals: { user } }))
// これにより、下流のミドルウェアやハンドラーに情報を渡すことができる
if (moreContext != null) {
Object.assign(context, moreContext)
}
// === 2-3-2. 次のミドルウェアをdispatch ===
// dispatch(i + 1)を呼び出して次のミドルウェアを実行
// 結果をnextPromiseに保存し、後で参照できるようにする
nextPromise = dispatch(i + 1)
return nextPromise
}
// === 2-4. 現在のミドルウェアを実行 ===
// ミドルウェアにcontextとnext関数を渡して実行
// ミドルウェアはnext()を呼ぶことで次に進むか、Responseを返してショートサーキットできる
let response = await fn(context, next)
// === 2-5. レスポンスが返された場合 ===
// ミドルウェアがResponseインスタンスを返した場合、
// これ以降のミドルウェア/ハンドラーをスキップしてすぐにレスポンスを返す
// 例: 認証ミドルウェアで認証失敗時に401レスポンスを返す
if (response instanceof Response) {
return response
}
// === 2-6. next()が呼ばれた場合 ===
// nextPromiseが定義されている場合、ミドルウェアがnext()を呼び出している
// next()の結果(下流のレスポンス)をそのまま返す
// これにより、ミドルウェアはnext()の後に後処理を実行できる(オニオンモデル)
if (nextPromise != null) {
return nextPromise
}
// === 2-7. next()が呼ばれなかった場合の自動フォールスルー ===
// ミドルウェアがnext()を呼ばずにundefined/voidを返した場合、
// 自動的にnext()を呼び出して次に進む
// これにより、明示的にnext()を呼ばなくてもミドルウェアチェーンが続行される
return next()
}
// === 3. ミドルウェアチェーンの開始 ===
// dispatch(0)で最初のミドルウェアから実行を開始
// 最終的にすべてのミドルウェアとハンドラーが実行され、Responseが返される
return dispatch(0)
}
この実装により、以下のようなミドルウェアパターンが可能になります:
- 前処理のみ実行して次に進む(fetch-router READMEより)
// グローバルミドルウェア - 全てのルートで実行
router.use((context, next) => {
console.log(`${context.request.method} ${context.url.pathname}`)
return next()
})
- 早期リターン(認証失敗など)(Bookstoreデモより)
export let requireAuth: Middleware = async ({ request, storage }) => {
let session = getSession(request)
let userId = getUserIdFromSession(session.sessionId)
if (!userId) {
return redirect(routes.auth.login.index, 302)
}
let user = getUserById(userId)
if (!user) {
return redirect(routes.auth.login.index, 302)
}
storage.set(USER_KEY, user)
storage.set(SESSION_ID_KEY, session.sessionId)
}
- レスポンスの加工(fetch-router READMEより)
// 複数のミドルウェア
router.post('/api/posts', [authenticate, validatePostData, rateLimit], async ({ request }) => {
let data = await request.json()
let post = await db.createPost(data)
return new Response(JSON.stringify(post), { status: 201 })
})
RequestContext - リクエスト情報の中央管理
全てのミドルウェアとハンドラーは、同じRequestContextインスタンスを受け取ります。
export class RequestContext<
Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY',
Params extends Record<string, any> = {},
> {
formData: Method extends RequestBodyMethod ? FormData : undefined
method: RequestMethod
params: Params
request: Request
storage: AppStorage
url: URL
headers: Headers
constructor(request: Request) {
this.formData = undefined as any
this.method = request.method.toUpperCase() as RequestMethod
this.params = {} as Params
this.request = request
this.storage = new AppStorage()
this.headers = new Headers(request.headers)
this.url = new URL(request.url)
}
get files(): Map<string, File> | null {
let formData = this.formData
if (formData == null) {
return null
}
let files: Map<string, File> = new Map()
for (let [key, value] of formData.entries()) {
if (value instanceof File) {
files.set(key, value)
}
}
return files
}
}
5. @remix-run/form-data-parser
概要
request.formData()のストリーミング版として機能し、大きなファイルアップロードをメモリに全てバッファせずに処理できます。
ディレクトリ構成
packages/form-data-parser/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ └── form-data.ts # parseFormData - メイン機能
│ # FileUpload型定義
│ # FileUploadHandler型定義
├── README.md
└── package.json
背景にある課題
ネイティブのrequest.formData()には以下の問題があります:
- 全てのファイルアップロードをメモリにバッファする
- ファイルアップロード処理の細かい制御ができない
- 悪意のあるリクエストからのDoS攻撃を防げない
サーバー環境では、大きなファイルアップロードでRAMを使い果たしてアプリケーションがクラッシュする可能性があります。
解決策
form-data-parserは、リクエストボディストリームからファイルアップロードを検出した時点で処理を開始し、ストリームとして安全にストレージに保存できます。
import * as fsp from 'node:fs/promises'
import type { FileUpload } from '@remix-run/form-data-parser'
import { parseFormData } from '@remix-run/form-data-parser'
async function uploadHandler(fileUpload: FileUpload) {
if (fileUpload.fieldName === 'user-avatar') {
let filename = `/uploads/user-${user.id}-avatar.bin`
// ファイルを安全にディスクに保存
await fsp.writeFile(filename, fileUpload.bytes)
// FormDataオブジェクトでファイルの内容をメモリに保持しないよう、
// ファイル名を返す
return filename
}
}
async function requestHandler(request: Request) {
// リクエストボディストリームからフォームデータをパース
// ファイルはストリームからパースされる際にアップロードハンドラーに渡される
let formData = await parseFormData(request, uploadHandler)
let avatarFilename = formData.get('user-avatar')
if (avatarFilename != null) {
console.log(`User avatar uploaded to ${avatarFilename}`)
}
}
file-storageとの組み合わせ
file-storageライブラリと組み合わせることで、より柔軟なストレージソリューションが実現できます。
import { LocalFileStorage } from '@remix-run/file-storage/local'
import type { FileUpload } from '@remix-run/form-data-parser'
import { parseFormData } from '@remix-run/form-data-parser'
const fileStorage = new LocalFileStorage('/uploads/user-avatars')
async function uploadHandler(fileUpload: FileUpload) {
if (fileUpload.fieldName === 'user-avatar') {
let storageKey = `user-${user.id}-avatar`
// ストレージにファイルを保存
await fileStorage.set(storageKey, fileUpload)
// 必要に応じてストレージからファイルにアクセスできる
// 遅延ロードFileオブジェクトを返す
return fileStorage.get(storageKey)
}
}
6. @remix-run/multipart-parser
概要
あらゆるJavaScript環境で動作する、高速でストリーミング可能なmultipartパーサーです。ファイルアップロード、メール添付ファイル、multipart APIレスポンスの解析に使用できます。
ディレクトリ構成
packages/multipart-parser/
├── src/
│ ├── index.ts # メインエクスポート(Web標準版)
│ ├── node.ts # Node.js専用エクスポート
│ └── lib/
│ ├── multipart.ts # parseMultipart - コアパーサー(Uint8Array)
│ │ # parseMultipartStream - ストリームパーサー
│ ├── multipart-request.ts # parseMultipartRequest - リクエストパーサー
│ ├── multipart.node.ts # Node.js版パーサー
│ ├── buffer-search.ts # バッファ検索アルゴリズム
│ └── read-stream.ts # ストリーム読み取りユーティリティ
├── demos/ # 実行環境別デモ
│ ├── bun/
│ ├── cf-workers/
│ ├── deno/
│ └── node/
├── README.md
└── package.json
特徴
- ユニバーサルJavaScript - Node.js、Bun、Deno、Cloudflare Workers、ブラウザで動作
- 超高速 - ベンチマークでbusboyなどの人気ライブラリを常に上回る性能
- ゼロ依存 - 外部依存なしで軽量かつセキュア
-
メモリ効率 - ストリーム内でファイルを見つけた時点で
yieldするストリーミングアーキテクチャ - 型安全 - TypeScriptで記述され、包括的な型定義を提供
- 標準ベース - Web標準のStreams APIで構築され、最大の互換性を実現
使用例
import { MultipartParseError, parseMultipartRequest } from '@remix-run/multipart-parser'
async function handleRequest(request: Request): void {
try {
for await (let part of parseMultipartRequest(request)) {
if (part.isFile) {
// 複数の形式でファイルデータにアクセス可能
let buffer = part.arrayBuffer // ArrayBuffer
console.log(`File received: ${part.filename} (${buffer.byteLength} bytes)`)
console.log(`Content type: ${part.mediaType}`)
console.log(`Field name: ${part.name}`)
// ディスクに保存、クラウドストレージにアップロードなど
await saveFile(part.filename, part.bytes)
} else {
let text = part.text // string
console.log(`Field received: ${part.name} = ${JSON.stringify(text)}`)
}
}
} catch (error) {
if (error instanceof MultipartParseError) {
console.error('Failed to parse multipart request:', error.message)
}
}
}
ファイルアップロードサイズ制限
import {
MultipartParseError,
MaxFileSizeExceededError,
parseMultipartRequest,
} from '@remix-run/multipart-parser/node'
const oneMb = Math.pow(2, 20)
const maxFileSize = 10 * oneMb
async function handleRequest(request: Request): Promise<Response> {
try {
for await (let part of parseMultipartRequest(request, { maxFileSize })) {
// ...
}
} catch (error) {
if (error instanceof MaxFileSizeExceededError) {
return new Response('File size limit exceeded', { status: 413 })
} else if (error instanceof MultipartParseError) {
return new Response('Failed to parse multipart request', { status: 400 })
} else {
console.error(error)
return new Response('Internal Server Error', { status: 500 })
}
}
}
Node.jsバインディング
Node.js固有のAPI(http.IncomingMessage、stream.Readable、buffer.Bufferなど)を使用するサーバーを構築する場合、専用のモジュールが提供されています。
import * as http from 'node:http'
import { MultipartParseError, parseMultipartRequest } from '@remix-run/multipart-parser/node'
let server = http.createServer(async (req, res) => {
try {
for await (let part of parseMultipartRequest(req)) {
// ...
}
} catch (error) {
if (error instanceof MultipartParseError) {
console.error('Failed to parse multipart request:', error.message)
}
}
})
server.listen(8080)
7. @remix-run/headers
概要
標準のHeadersインターフェースをスーパーチャージし、型安全で堅牢なヘッダー操作ツールキットを提供します。
ディレクトリ構成
packages/headers/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ ├── super-headers.ts # SuperHeaders - 拡張Headersクラス
│ ├── accept.ts # Accept ヘッダー
│ ├── accept-encoding.ts # Accept-Encoding ヘッダー
│ ├── accept-language.ts # Accept-Language ヘッダー
│ ├── cache-control.ts # Cache-Control ヘッダー
│ ├── content-disposition.ts # Content-Disposition ヘッダー
│ ├── content-type.ts # Content-Type ヘッダー
│ ├── cookie.ts # Cookie ヘッダー
│ ├── set-cookie.ts # Set-Cookie ヘッダー
│ ├── if-none-match.ts # If-None-Match ヘッダー
│ ├── header-names.ts # ヘッダー名の定数
│ ├── header-value.ts # ヘッダー値のパース/文字列化
│ ├── param-values.ts # パラメータ値のパース
│ └── utils.ts # ユーティリティ関数
├── README.md
└── package.json
主要機能
型安全なアクセサ
複雑なヘッダー値(メディアタイプ、品質ファクター、Cookie属性など)を、強く型付けされたプロパティとメソッドで操作できます。
import Headers from '@remix-run/headers'
let headers = new Headers()
// Accept
headers.accept = 'text/html, text/*;q=0.9'
headers.accept.mediaTypes // [ 'text/html', 'text/*' ]
Object.fromEntries(headers.accept.entries()) // { 'text/html': 1, 'text/*': 0.9 }
headers.accept.accepts('text/html') // true
headers.accept.accepts('text/plain') // true
headers.accept.accepts('image/jpeg') // false
headers.accept.getPreferred(['text/plain', 'text/html']) // 'text/html'
headers.accept.set('text/plain', 0.9)
headers.accept.set('text/*', 0.8)
headers.get('Accept') // 'text/html,text/plain;q=0.9,text/*;q=0.8'
Content-Type
headers.contentType = 'application/json; charset=utf-8'
headers.contentType.mediaType // "application/json"
headers.contentType.charset // "utf-8"
headers.contentType.charset = 'iso-8859-1'
headers.get('Content-Type') // "application/json; charset=iso-8859-1"
Cookie & Set-Cookie
// Cookie
headers.cookie = 'session_id=abc123; user_id=12345'
headers.cookie.get('session_id') // 'abc123'
headers.cookie.set('theme', 'dark')
headers.get('Cookie') // 'session_id=abc123; user_id=12345; theme=dark'
// Set-Cookie
headers.setCookie = ['session_id=abc123; Path=/; HttpOnly']
headers.setCookie[0].name // 'session_id'
headers.setCookie[0].value // 'abc123'
headers.setCookie[0].path // '/'
headers.setCookie[0].httpOnly // true
headers.setCookie[0].maxAge = 3600
headers.setCookie[0].secure = true
headers.get('Set-Cookie') // 'session_id=abc123; Path=/; HttpOnly; Max-Age=3600; Secure'
Cache-Control
import { CacheControl } from '@remix-run/headers'
let header = new CacheControl('public, max-age=3600, s-maxage=3600')
header.public // true
header.maxAge // 3600
header.sMaxage // 3600
// オブジェクト初期化
let header = new CacheControl({ public: true, maxAge: 3600 })
個別ヘッダーユーティリティクラス
Headersクラスの高レベルAPIに加えて、個別のヘッダーを扱うための豊富なプリミティブセットも提供されます。各ヘッダークラスには、仕様準拠のパーサー(コンストラクタ)、文字列化機能(toString)、および全ての関連属性のゲッター/セッターが含まれています。
8. @remix-run/file-storage
概要
JavaScriptでFileオブジェクトを保存するためのキー/バリューインターフェースです。ブラウザのlocalStorageが文字列のキー/バリューペアを保存するのと同様に、file-storageはサーバー上でファイルのキー/バリューペアを保存します。
ディレクトリ構成
packages/file-storage/
├── src/
│ ├── index.ts # メインエクスポート
│ ├── local.ts # LocalFileStorage専用エクスポート
│ ├── memory.ts # MemoryFileStorage専用エクスポート
│ └── lib/
│ ├── file-storage.ts # FileStorageインターフェース
│ ├── local-file-storage.ts # LocalFileStorage実装
│ └── memory-file-storage.ts # MemoryFileStorage実装
├── README.md
└── package.json
使用例
import { LocalFileStorage } from '@remix-run/file-storage/local'
let storage = new LocalFileStorage('./user/files')
let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' })
let key = 'hello-key'
// ストレージにファイルを保存
await storage.set(key, file)
// 後で取り出す
let fileFromStorage = await storage.get(key)
// 元のファイルのメタデータは全て保持される
fileFromStorage.name // 'hello.txt'
fileFromStorage.type // 'text/plain'
// ストレージから削除
await storage.remove(key)
カスタムストレージバックエンドの実装
FileStorageインターフェースを実装することで、独自のファイルストレージを作成できます。
import { type FileStorage } from '@remix-run/file-storage'
class CustomFileStorage implements FileStorage {
has(key: string): boolean | Promise<boolean> {
// キーが存在するか確認
}
set(key: string, file: File): void | Promise<void> {
// ファイルをストレージに保存
}
get(key: string): File | null | Promise<File | null> {
// キーに対応するファイルを取得
}
remove(key: string): void | Promise<void> {
// ストレージからファイルを削除
}
}
9. @remix-run/lazy-file
概要
遅延ロード可能でストリーミング対応のBlob/File実装です。ファイルの内容が必要になるまで読み込みを遅延させ、メモリ使用量を最小限に抑えます。
ディレクトリ構成
packages/lazy-file/
├── src/
│ ├── index.ts # メインエクスポート(LazyBlob, LazyFile)
│ ├── fs.ts # ファイルシステム統合(openFile, writeFile)
│ └── lib/
│ ├── lazy-file.ts # LazyFile, LazyBlob実装
│ └── byte-range.ts # バイト範囲の計算
├── README.md
└── package.json
背景にある課題
JavaScriptのFile APIは便利ですが、ストリーミングサーバー環境には適していません。特にFile()コンストラクタは、オブジェクト作成時にファイルの内容を事前に提供する必要があります。
// 標準のFile - 内容を事前にロード
let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' })
解決策
LazyFileは、コンストラクタでLazyContentという追加のコンテンツタイプを受け付けます。
import { type LazyContent, LazyFile } from '@remix-run/lazy-file'
let content: LazyContent = {
// ファイルの総バイト長
byteLength: 100000,
// ファイル内容のストリームを提供する関数
// startインデックスから始まり、endで終わる
stream(start, end) {
return new ReadableStream({
start(controller) {
controller.enqueue('X'.repeat(100000).slice(start, end))
controller.close()
},
})
},
}
let file = new LazyFile(content, 'example.txt', { type: 'text/plain' })
await file.arrayBuffer() // ファイル内容のArrayBuffer
file.name // "example.txt"
file.type // "text/plain"
ファイルシステムとの統合
lazy-file/fsエクスポートは、ローカルファイルシステムとの読み書きのための関数を提供します。
import { openFile, writeFile } from '@remix-run/lazy-file/fs'
// この時点ではデータは読み込まれず、ローカルファイルシステム上の
// ファイルへの参照のみ
let file = openFile('./path/to/file.json')
// file.text()(またはfile.bytes()、file.stream()などの他のBlobメソッド)を
// 呼び出した時にデータが読み込まれる
let json = JSON.parse(await file.text())
// ファイルの内容を別のパスに書き戻す
await writeFile('./path/to/other-file.json', file)
let imageFile = openFile('./path/to/image.jpg')
// ファイルの最初の100バイトを省略したLazyBlobを取得
// HTTP Rangeリクエストを処理する際などに便利
let blob = imageFile.slice(100)
全てのファイル内容はオンデマンドで読み込まれ、何もバッファリングされません。
10. @remix-run/fetch-proxy
概要
JavaScript Fetch API用のHTTPプロキシです。サーバーコンテキストでは、HTTPプロキシサーバーは受信した全てのリクエストを別のサーバーに転送し、受信したレスポンスを返すサーバーです。
ディレクトリ構成
packages/fetch-proxy/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ └── fetch-proxy.ts # createFetchProxy実装
├── README.md
└── package.json
使用例
import { createFetchProxy } from '@remix-run/fetch-proxy'
// 全てのリクエストをremix.runに送信するプロキシを作成
let proxy = createFetchProxy('https://remix.run')
// このfetchハンドラーはサーバーのどこかで実行されている
function handleFetch(request: Request): Promise<Response> {
return proxy(request)
}
// 手動でRequestを投げてテスト
let response = await handleFetch(new Request('https://shopify.com'))
let text = await response.text()
let title = text.match(/<title>([^<]+)<\/title>/)[1]
assert(title.includes('Remix'))
機能
- 標準のJavaScript Fetch APIで構築
- ターゲットサーバーから受信した
Set-Cookieヘッダーの書き換えをサポート -
X-Forwarded-ProtoとX-Forwarded-Hostヘッダーをサポート - カスタム
fetch実装をサポート
11. @remix-run/tar-parser
概要
あらゆるJavaScript環境で動作する、高速で効率的なtarアーカイブパーサーです。
ディレクトリ構成
packages/tar-parser/
├── src/
│ ├── index.ts # メインエクスポート
│ └── lib/
│ ├── tar.ts # parseTar - メインパーサー
│ ├── utils.ts # ユーティリティ関数
│ └── read-stream.ts # ストリーム読み取り
├── README.md
└── package.json
特徴
- JavaScriptが動作する環境ならどこでも実行可能
- 標準のWeb Streams APIで構築され、
fetch()ストリームと組み合わせ可能 - POSIX、GNU、PAX tarフォーマットをサポート
- メモリ効率的で、通常の使用では何もバッファリングしない
- 依存関係ゼロ
使用例
import { parseTar } from '@remix-run/tar-parser'
let response = await fetch('https://github.com/remix-run/remix/archive/refs/heads/main.tar.gz')
await parseTar(response.body.pipeThrough(new DecompressionStream('gzip')), (entry) => {
console.log(entry.name, entry.size)
})
UTF-8以外のエンコーディング
let response = await fetch(/* ... */)
await parseTar(response.body, { filenameEncoding: 'latin1' }, (entry) => {
console.log(entry.name, entry.size)
})
実装の実行フロー
ここでは、HTTPリクエストがRemix 3アプリケーションに到達してから、最終的にHTTPレスポンスとして返されるまでの完全な実行フローを、実際のコードと共に詳細に解説します。
フェーズ1: HTTPリクエストの受信とWeb Request変換
1.1 Node.js HTTPサーバーの起動
import * as http from 'node:http'
import { createRequestListener } from '@remix-run/node-fetch-server'
import { router } from './app/router.ts'
let server = http.createServer(
createRequestListener(async (request) => {
try {
return await router.fetch(request)
} catch (error) {
console.error(error)
return new Response('Internal Server Error', { status: 500 })
}
}),
)
server.listen(44100)
処理の流れ:
-
http.createServer()でNode.jsのHTTPサーバーを作成 -
createRequestListener()でFetchハンドラーをNode.jsのリクエストリスナーにラップ - クライアントからHTTPリクエストが到着すると、Node.jsは
http.IncomingMessageとhttp.ServerResponseオブジェクトを生成
1.2 Request変換 - createRequestListener()
export function createRequestListener(
handler: FetchHandler,
options?: RequestListenerOptions,
): http.RequestListener {
let onError = options?.onError ?? defaultErrorHandler
return async (req, res) => {
// Node.jsのIncomingMessageからWeb標準のRequestを作成
let request = createRequest(req, res, options)
// クライアントの接続情報を抽出
let client = {
address: req.socket.remoteAddress!,
family: req.socket.remoteFamily! as ClientAddress['family'],
port: req.socket.remotePort!,
}
let response: Response
try {
// Fetchハンドラーを呼び出し
response = await handler(request, client)
} catch (error) {
try {
response = (await onError(error)) ?? internalServerError()
} catch (error) {
console.error(`There was an error in the error handler: ${error}`)
response = internalServerError()
}
}
// Web ResponseをNode.jsのServerResponseに変換して送信
await sendResponse(res, response)
}
}
1.3 Request オブジェクトの構築 - createRequest()
export function createRequest(
req: http.IncomingMessage | http2.Http2ServerRequest,
res: http.ServerResponse | http2.Http2ServerResponse,
options?: RequestOptions,
): Request {
let controller: AbortController | null = new AbortController()
// レスポンスが書き込めなくなったら中断
res.once('close', () => controller?.abort())
res.once('finish', () => (controller = null))
let method = req.method ?? 'GET'
let headers = createHeaders(req)
// プロトコルの決定(HTTPSかHTTPか)
let protocol =
options?.protocol ?? ('encrypted' in req.socket && req.socket.encrypted ? 'https:' : 'http:')
let host = options?.host ?? headers.get('Host') ?? 'localhost'
let url = new URL(req.url!, `${protocol}//${host}`)
let init: RequestInit = { method, headers, signal: controller.signal }
// GET/HEAD以外はボディをストリームとして設定
if (method !== 'GET' && method !== 'HEAD') {
init.body = new ReadableStream({
start(controller) {
req.on('data', (chunk) => {
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
})
req.on('end', () => {
controller.close()
})
},
})
;(init as { duplex: 'half' }).duplex = 'half'
}
return new Request(url, init)
}
重要なポイント:
- Node.jsの
http.IncomingMessageをストリーミングで読み取り、Web標準のReadableStreamに変換 -
AbortControllerを使用してリクエストのキャンセル処理を実装 - ヘッダー、URL、メソッドなどをWeb標準形式に変換
フェーズ2: Routerによるディスパッチ
2.1 Router.fetch() - エントリーポイント
async fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
let request = input instanceof Request ? input : new Request(input, init)
// ディスパッチしてレスポンスを取得
let response = await this.dispatch(request)
// マッチしなかった場合、デフォルトハンドラー(通常は404)を実行
if (response == null) {
response = await this.#defaultHandler(new RequestContext(request))
}
return response
}
2.2 リクエストのパース - #parseRequest()
ルーティングの前に、リクエストのボディをパースします。
async #parseRequest(request: Request): Promise<RequestContext> {
let context = new RequestContext(request)
// フォームデータのパースが無効化されている場合はスキップ
if (this.#parseFormData === false) {
return context
}
// フォームデータをパースすべきか判定
if (shouldParseFormData(request)) {
let suppressParseErrors: boolean
let parseOptions: ParseFormDataOptions
if (this.#parseFormData === true) {
suppressParseErrors = false
parseOptions = {}
} else {
suppressParseErrors = this.#parseFormData.suppressErrors ?? false
parseOptions = this.#parseFormData
}
try {
// @remix-run/form-data-parserを使用してパース
context.formData = await parseFormData(request, parseOptions, this.#uploadHandler)
} catch (error) {
if (!suppressParseErrors || !(error instanceof FormDataParseError)) {
throw error
}
// パースエラーを抑制、空のFormDataで続行
context.formData = new FormData()
}
} else {
context.formData = new FormData()
}
// メソッドオーバーライドの処理(HTMLフォームでPUT/DELETEをサポート)
if (this.#methodOverride) {
let fieldName = this.#methodOverride === true ? '_method' : this.#methodOverride
let methodOverride = context.formData.get(fieldName)
if (typeof methodOverride === 'string' && methodOverride !== '') {
context.method = methodOverride.toUpperCase() as RequestMethod
}
}
return context
}
重要なポイント:
-
parseFormData()は@remix-run/form-data-parserを使用してストリーミングでフォームデータをパース - アップロードハンドラーを使用して、ファイルアップロードをメモリにバッファせずに処理
- メソッドオーバーライド機能により、HTMLフォームから
PUTやDELETEリクエストをシミュレート可能
2.3 ルートマッチングとディスパッチ - dispatch()
async dispatch(
request: Request | RequestContext,
upstreamMiddleware?: Middleware[],
): Promise<Response | null> {
// RequestContextの取得または作成
let context = request instanceof Request ? await this.#parseRequest(request) : request
// マッチャーから全てのマッチ候補を取得し、順番に試す
for (let match of this.#matcher.matchAll(context.url)) {
// サブルーターへのマッチの場合
if ('router' in match.data) {
let { middleware: mountMiddleware, prefix, router } = match.data
// 元のURLを保存
let originalUrl = context.url
// マウントポイントをパス名から取り除いた新しいURLを作成
let strippedUrl = new URL(match.url)
strippedUrl.pathname = strippedUrl.pathname.slice(prefix.length)
context.url = strippedUrl
// サブルーターにディスパッチ
// 上流のミドルウェアとマウントポイントのミドルウェアを結合して渡す
let response = await router.dispatch(
context,
concatMiddleware(upstreamMiddleware, mountMiddleware),
)
// 元のURLを復元
context.url = originalUrl
if (response != null) {
return response
}
// サブルーターでマッチしなかった場合、次のマッチ候補へ
continue
}
// 通常のルートハンドラーの場合
let { method, middleware: routeMiddleware, handler } = match.data
// HTTPメソッドが一致するかチェック
if (method !== context.method && method !== 'ANY') {
continue
}
// contextにパラメータとマッチしたURLを設定
context.params = match.params
context.url = match.url
// 全てのミドルウェアを結合(上流 + ルート固有)
let middleware = concatMiddleware(upstreamMiddleware, routeMiddleware)
// ミドルウェアがある場合は実行、なければ直接ハンドラーを呼び出す
return middleware != null
? await runMiddleware(middleware, context, handler)
: await handler(context)
}
// どのルートにもマッチしなかった
return null
}
処理の流れ:
-
matcher.matchAll()で全てのマッチ候補を取得(RoutePatternによるマッチング) - 各マッチ候補について順番に検証
- サブルーターの場合:パス名をストリップして再帰的にディスパッチ
- 通常のルートの場合:HTTPメソッドをチェック後、ミドルウェアとハンドラーを実行
- どれにもマッチしない場合、
nullを返す(fetch()がデフォルトハンドラーを実行)
フェーズ3: パターンマッチング
3.1 RoutePattern.match() - URLマッチング
match(url: URL | string): RouteMatch<T> | null {
if (typeof url === 'string') url = new URL(url)
// パターンをコンパイル(初回のみ、その後はキャッシュ)
let { matchOrigin, matcher, paramNames } = this.#compile()
// 大文字小文字を無視する設定の場合、パス名を小文字に変換
let pathname = this.ignoreCase ? url.pathname.toLowerCase() : url.pathname
// 正規表現でマッチング
let match = matcher.exec(matchOrigin ? `${url.origin}${pathname}` : pathname)
if (match === null) return null
// キャプチャグループをパラメータ名にマッピング
let params = {} as any
for (let i = 0; i < paramNames.length; i++) {
let paramName = paramNames[i]
params[paramName] = match[i + 1]
}
// 検索パラメータの制約をチェック
if (
this.#parsed.searchConstraints != null &&
!matchSearch(url.search, this.#parsed.searchConstraints)
) {
return null
}
return { url, params }
}
3.2 パターンのコンパイル - compilePattern()
パターン文字列を正規表現に変換します。この処理は初回のみ実行され、結果はキャッシュされます。
function compilePattern(parsed: ParseResult, ignoreCase: boolean): CompileResult {
let { protocol, hostname, port, pathname } = parsed
let matchOrigin = hostname !== undefined
let matcher: RegExp
let paramNames: string[] = []
if (matchOrigin) {
// 完全なURL(プロトコル、ホスト名、ポート、パス名)をマッチング
let protocolSource = protocol
? tokensToRegExpSource(protocol, '', '.*', paramNames, true)
: '[^:]+'
let hostnameSource = hostname
? tokensToRegExpSource(hostname, '.', '[^.]+?', paramNames, true)
: '[^/:]+'
let portSource = port !== undefined ? `:${regexpEscape(port)}` : '(?::[0-9]+)?'
let pathnameSource = pathname
? tokensToRegExpSource(pathname, '/', '[^/]+?', paramNames, ignoreCase)
: ''
matcher = new RegExp(`^${protocolSource}://${hostnameSource}${portSource}/${pathnameSource}$`)
} else {
// パス名のみをマッチング
let pathnameSource = pathname
? tokensToRegExpSource(pathname, '/', '[^/]+?', paramNames, ignoreCase)
: ''
matcher = new RegExp(`^/${pathnameSource}$`)
}
return { matchOrigin, matcher, paramNames }
}
3.3 トークンから正規表現への変換 - tokensToRegExpSource()
パターンのトークン(テキスト、変数、ワイルドカード、オプショナルなど)を正規表現の文字列に変換します。
function tokensToRegExpSource(
tokens: Token[],
sep: string,
paramRegExpSource: string,
paramNames: string[],
forceLowerCase: boolean,
): string {
let source = ''
for (let token of tokens) {
if (token.type === 'variable') {
// :paramName → キャプチャグループ
paramNames.push(token.name)
source += `(${paramRegExpSource})`
} else if (token.type === 'wildcard') {
if (token.name) {
// *paramName → 全てをキャプチャ
paramNames.push(token.name)
source += `(.*)`
} else {
// * → キャプチャしない
source += `(?:.*)`
}
} else if (token.type === 'text') {
// 通常のテキスト → エスケープして追加
source += regexpEscape(forceLowerCase ? token.value.toLowerCase() : token.value)
} else if (token.type === 'separator') {
// セパレータ(/や.など)
source += regexpEscape(sep)
} else if (token.type === 'optional') {
// (optional) → 非キャプチャグループ + ?
source += `(?:${tokensToRegExpSource(token.tokens, sep, paramRegExpSource, paramNames, forceLowerCase)})?`
}
}
return source
}
例: パターンのコンパイル
パターン: '/blog/:year-:month-:day/:slug(.html)'
-
パース結果のトークン:
text: 'blog'separator: '/'variable: 'year'text: '-'variable: 'month'text: '-'variable: 'day'separator: '/'variable: 'slug'optional: [text: '.html']
-
生成される正規表現:
^/blog/([^/]+?)-([^/]+?)-([^/]+?)/([^/]+?)(?:\.html)?$ -
paramNames:['year', 'month', 'day', 'slug'] -
URLマッチング:
'/blog/2024-01-15/introducing-remix.html' → match[1] = '2024' (year) → match[2] = '01' (month) → match[3] = '15' (day) → match[4] = 'introducing-remix' (slug)
フェーズ4: ミドルウェアの実行
4.1 ミドルウェア実行 - runMiddleware()
export function runMiddleware<
Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY',
Params extends Record<string, any> = {},
>(
middleware: Middleware<Method, Params>[],
context: RequestContext<Method, Params>,
handler: RequestHandler<Method, Params, Response>,
): Promise<Response> {
let index = -1
let dispatch = async (i: number): Promise<Response> => {
// next()が複数回呼ばれることを防ぐ
if (i <= index) throw new Error('next() called multiple times')
index = i
// 全てのミドルウェアを処理済みの場合、最終的なハンドラーを実行
let fn = middleware[i]
if (!fn) return handler(context)
let nextPromise: Promise<Response> | undefined
// next関数を定義
let next: NextFunction = (moreContext?: Partial<RequestContext>) => {
if (moreContext != null) {
// コンテキストの追加/上書き
Object.assign(context, moreContext)
}
// 次のミドルウェア/ハンドラーをディスパッチ
nextPromise = dispatch(i + 1)
return nextPromise
}
// ミドルウェア関数を実行
let response = await fn(context, next)
// レスポンスが返された場合、チェーンをショートサーキット
if (response instanceof Response) {
return response
}
// next()が呼ばれた場合、その結果を使用
if (nextPromise != null) {
return nextPromise
}
// next()が呼ばれなかった場合、自動的に下流を呼び出す
return next()
}
return dispatch(0)
}
オニオンモデルの仕組み:
リクエスト
↓
[Middleware 1] → 前処理
↓
[Middleware 2] → 前処理
↓
[Middleware 3] → 前処理
↓
[Handler] → レスポンス生成
↑
[Middleware 3] ← 後処理
↑
[Middleware 2] ← 後処理
↑
[Middleware 1] ← 後処理
↑
レスポンス
4.2 実際のミドルウェア例
ロガーミドルウェア(fetch-router実装より)
export function logger(options: LoggerOptions = {}): Middleware {
let { format = '[%date] %method %path %status %contentLength', log = console.log } = options
return async ({ request, url }, next) => {
let start = new Date()
let response = await next()
let end = new Date()
let tokens: Record<string, () => string> = {
date: () => formatApacheDate(start),
dateISO: () => start.toISOString(),
duration: () => String(end.getTime() - start.getTime()),
contentLength: () => response.headers.get('Content-Length') ?? '-',
contentType: () => response.headers.get('Content-Type') ?? '-',
host: () => url.host,
hostname: () => url.hostname,
method: () => request.method,
path: () => url.pathname + url.search,
pathname: () => url.pathname,
port: () => url.port,
protocol: () => url.protocol,
query: () => url.search,
referer: () => request.headers.get('Referer') ?? '-',
search: () => url.search,
status: () => String(response.status),
statusText: () => response.statusText,
url: () => url.href,
userAgent: () => request.headers.get('User-Agent') ?? '-',
}
let message = format.replace(/%(\w+)/g, (_, key) => tokens[key]?.() ?? '-')
log(message)
}
}
認証ミドルウェア(Bookstoreデモより)
export let requireAuth: Middleware = async ({ request, storage }) => {
let session = getSession(request)
let userId = getUserIdFromSession(session.sessionId)
if (!userId) {
return redirect(routes.auth.login.index, 302)
}
let user = getUserById(userId)
if (!user) {
return redirect(routes.auth.login.index, 302)
}
// storageにユーザー情報を保存(次のミドルウェア/ハンドラーで使用可能)
storage.set(USER_KEY, user)
storage.set(SESSION_ID_KEY, session.sessionId)
// next()を呼ばないが、runMiddlewareが自動的に次を呼び出す
}
フェーズ5: ハンドラーの実行
ミドルウェアチェーンの最後で、実際のルートハンドラーが実行されます。
5.1 Bookstoreデモのハンドラー例
export default {
use: [loadAuth], // このルート用のミドルウェア
handlers: {
// GET /books
index() {
let books = getAllBooks()
let genres = getAvailableGenres()
return render(
<Layout>
<h1>Browse Books</h1>
{/* ... JSX ... */}
</Layout>,
)
},
// GET /books/:slug
show({ params }) {
let book = getBookBySlug(params.slug)
if (!book) {
return render(
<Layout>
<div class="card">
<h1>Book Not Found</h1>
</div>
</Layout>,
{ status: 404 },
)
}
return render(
<Layout>
<div style="display: grid; grid-template-columns: 300px 1fr; gap: 2rem;">
{/* ... 書籍詳細のJSX ... */}
</div>
</Layout>,
)
},
// GET /books/genre/:genre
genre({ params }) {
let genre = params.genre
let books = getBooksByGenre(genre)
if (books.length === 0) {
return render(
<Layout>
<div class="card">
<h1>Genre Not Found</h1>
<p>No books found in the "{genre}" genre.</p>
</div>
</Layout>,
{ status: 404 },
)
}
return render(
<Layout>
<h1>{genre.charAt(0).toUpperCase() + genre.slice(1)} Books</h1>
{/* ... ジャンル別書籍一覧のJSX ... */}
</Layout>,
)
},
},
} satisfies RouteHandlers<typeof routes.books>
ハンドラーが受け取るcontext:
-
params: RoutePatternから抽出されたパラメータ(型安全) -
request: 元のRequest オブジェクト -
url: マッチしたURL -
formData: パース済みのFormData -
files: アップロードされたファイルのMap -
storage: ミドルウェア間でデータを共有するためのストレージ -
method: HTTPメソッド(メソッドオーバーライド適用後)
フェーズ6: レスポンスの送信
6.1 Responseの生成
ハンドラーまたはミドルウェアがWeb標準のResponseオブジェクトを返します。
// HTML レスポンス
return new Response(htmlString, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
})
// JSON レスポンス
return Response.json({ message: 'Success' })
// リダイレクト
return Response.redirect('/login', 302)
// ストリーミングレスポンス
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
6.2 sendResponse() - Web Response → Node.js Response変換
export async function sendResponse(
res: http.ServerResponse | http2.Http2ServerResponse,
response: Response,
): Promise<void> {
// Set-Cookieなどの複数値ヘッダーを正しく処理
let headers: Record<string, string | string[]> = {}
for (let [key, value] of response.headers) {
if (key in headers) {
if (Array.isArray(headers[key])) {
headers[key].push(value)
} else {
headers[key] = [headers[key] as string, value]
}
} else {
headers[key] = value
}
}
// HTTPバージョンに応じてヘッダーを送信
if (res.req.httpVersionMajor === 1) {
// HTTP/1.x はステータスメッセージをサポート
res.writeHead(response.status, response.statusText, headers)
} else {
// HTTP/2 はステータスメッセージをサポートしない
res.writeHead(response.status, headers)
}
// ボディがある場合(かつHEADリクエストでない場合)、ストリーミングで送信
if (response.body != null && res.req.method !== 'HEAD') {
for await (let chunk of readStream(response.body)) {
// バックプレッシャーを処理
if (res.write(chunk) === false) {
await new Promise<void>((resolve) => {
res.once('drain', resolve)
})
}
}
}
res.end()
}
重要なポイント:
-
Response.body(ReadableStream)をNode.jsのServerResponseにストリーミング - バックプレッシャーを適切に処理(
write()がfalseを返した場合、drainイベントを待つ) - 複数のSet-Cookieヘッダーを配列として正しく送信
- HTTP/2とHTTP/1.xの違いを吸収
完全な実行フローのまとめ
1. クライアント
↓ HTTP Request
2. Node.js http.createServer()
↓ http.IncomingMessage + http.ServerResponse
3. createRequestListener()
↓ createRequest()
├─ ヘッダー変換
├─ URL構築
└─ ボディをReadableStreamに変換
↓ Web Request
4. Router.fetch()
↓ Router.dispatch()
5. Router.#parseRequest()
├─ RequestContextの作成
├─ parseFormData()でフォームデータをパース
│ └─ multipart-parserでストリーミングパース
│ └─ uploadHandlerでファイルを処理
└─ メソッドオーバーライドの適用
↓ RequestContext
6. Matcher.matchAll()
├─ RoutePattern.match()
│ ├─ パターンをコンパイル(初回のみ)
│ ├─ 正規表現でマッチング
│ └─ パラメータを抽出
└─ 全てのマッチ候補を取得
↓ Match[]
7. マッチング結果の処理(順番に試行)
├─ サブルーターの場合
│ ├─ パス名をストリップ
│ ├─ 上流のミドルウェアを渡して再帰的にdispatch()
│ └─ レスポンスがあれば返す
│
└─ 通常のルートの場合
├─ HTTPメソッドをチェック
└─ ミドルウェア + ハンドラーを実行
↓
8. runMiddleware()
├─ ミドルウェア1
│ ├─ 前処理
│ ├─ next() → ミドルウェア2
│ └─ 後処理
│
├─ ミドルウェア2
│ ├─ 前処理
│ ├─ next() → ハンドラー
│ └─ 後処理
│
└─ ハンドラー
├─ ビジネスロジック
├─ データ取得
└─ Responseを生成
↓ Response
9. sendResponse()
├─ ヘッダーを変換して送信
└─ ボディをストリーミングで送信
└─ バックプレッシャー処理
↓ http.ServerResponse
10. Node.js http.ServerResponse
↓ HTTP Response
11. クライアント
実践例: Bookstoreデモの処理フロー
ここでは、実際のBookstoreデモアプリケーションで、特定のリクエストがどのように処理されるかを、完全なコードと共にトレースします。
シナリオ: 書籍詳細ページへのアクセス
リクエスト: GET /books/the-great-gatsby
ステップ1: サーバーのセットアップ
import * as http from 'node:http'
import { createRequestListener } from '@remix-run/node-fetch-server'
import { router } from './app/router.ts'
let server = http.createServer(
createRequestListener(async (request) => {
try {
return await router.fetch(request)
} catch (error) {
console.error(error)
return new Response('Internal Server Error', { status: 500 })
}
}),
)
server.listen(44100)
ステップ2: ルーターの初期化
import { createRouter } from '@remix-run/fetch-router'
import { logger } from '@remix-run/fetch-router/logger-middleware'
import { routes } from '../routes.ts'
import { storeContext } from './middleware/context.ts'
import { uploadHandler } from './utils/uploads.ts'
import booksHandlers from './books.tsx'
export let router = createRouter({ uploadHandler })
// グローバルミドルウェア
router.use(storeContext)
if (process.env.NODE_ENV === 'development') {
router.use(logger())
}
// ルートマッピング
router.map(routes.books, booksHandlers)
ステップ3: ルート定義
import { route, resources } from '@remix-run/fetch-router'
export let routes = route({
home: '/',
about: '/about',
// ... 他のルート ...
books: route('/books', {
index: '/',
genre: '/genre/:genre',
show: '/:slug',
}),
// ... 他のルート ...
})
これにより、以下のルートが生成されます:
-
routes.books.index→/books/ -
routes.books.genre→/books/genre/:genre -
routes.books.show→/books/:slug
ステップ4: リクエストの処理開始
URL: http://localhost:44100/books/the-great-gatsby
-
Node.jsがHTTPリクエストを受信
-
createRequestListener()がhttp.IncomingMessageをRequestに変換Request { method: 'GET', url: 'http://localhost:44100/books/the-great-gatsby', headers: Headers { ... }, body: null } -
router.fetch(request)を呼び出し
ステップ5: Router.dispatch()によるマッチング
-
RequestContextを作成RequestContext { method: 'GET', url: URL { pathname: '/books/the-great-gatsby', ... }, params: {}, request: Request { ... }, storage: AppStorage {}, headers: Headers { ... }, formData: FormData {} // GETなので空 } -
matcher.matchAll(context.url)でマッチング候補を取得マッチング試行順序:
-
/books/→ マッチしない(パターンが完全一致を要求) -
/books/genre/:genre→ マッチしない('genre'セグメントがない) -
/books/:slug→ マッチ!Match { url: URL { pathname: '/books/the-great-gatsby', ... }, params: { slug: 'the-great-gatsby' }, data: { method: 'GET', middleware: [loadAuth], handler: booksHandlers.show } }
-
-
HTTPメソッドチェック:
'GET' === 'GET'→ OK -
contextを更新
context.params = { slug: 'the-great-gatsby' } context.url = URL { pathname: '/books/the-great-gatsby', ... }
ステップ6: ミドルウェアの実行
ミドルウェアスタック:
-
storeContext(グローバル) -
logger()(グローバル、開発環境のみ) -
loadAuth(ルート固有)
実行フロー:
// 1. storeContext ミドルウェア
async function storeContext(context, next) {
// ストアのコンテキストを設定
context.storage.set('store', getStore())
// 次のミドルウェアへ
return next()
}
// 2. logger ミドルウェア
async function logger(context, next) {
console.log('→ GET /books/the-great-gatsby')
let response = await next()
console.log('← 200')
return response
}
// 3. loadAuth ミドルウェア
async function loadAuth({ request, storage }, next) {
let session = getSession(request)
if (session) {
let userId = getUserIdFromSession(session.sessionId)
let user = getUserById(userId)
if (user) {
storage.set(USER_KEY, user)
storage.set(SESSION_ID_KEY, session.sessionId)
}
}
// ユーザーがいてもいなくても次へ進む(requireAuthとは異なる)
return next()
}
ステップ7: ハンドラーの実行
export default {
use: [loadAuth],
handlers: {
show({ params }) {
// paramsは型安全: { slug: string }
let book = getBookBySlug(params.slug) // 'the-great-gatsby'
if (!book) {
return render(
<Layout>
<div class="card">
<h1>Book Not Found</h1>
</div>
</Layout>,
{ status: 404 },
)
}
// 書籍が見つかった場合
return render(
<Layout>
<div style="display: grid; grid-template-columns: 300px 1fr; gap: 2rem;">
<div css={{ height: '400px', borderRadius: '8px', /* ... */ }}>
<ImageCarousel images={book.imageUrls} />
</div>
<div class="card">
<h1>{book.title}</h1>
<p class="author" style="font-size: 1.2rem; margin: 0.5rem 0;">
by {book.author}
</p>
<p style="margin: 1rem 0;">
<span class="badge badge-info">{book.genre}</span>
<span
class={`badge ${book.inStock ? 'badge-success' : 'badge-warning'}`}
style="margin-left: 0.5rem;"
>
{book.inStock ? 'In Stock' : 'Out of Stock'}
</span>
</p>
<p class="price" style="font-size: 2rem; margin: 1rem 0;">
${book.price.toFixed(2)}
</p>
<p style="margin: 1.5rem 0; line-height: 1.8;">{book.description}</p>
<div style="margin: 1.5rem 0; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
<p><strong>ISBN:</strong> {book.isbn}</p>
<p><strong>Published:</strong> {book.publishedYear}</p>
</div>
{book.inStock ? (
<form method="POST" action={routes.cart.api.add.href()} style="margin-top: 2rem;">
<input type="hidden" name="bookId" value={book.id} />
<input type="hidden" name="slug" value={book.slug} />
<button type="submit" class="btn" style="font-size: 1.1rem; padding: 0.75rem 1.5rem;">
Add to Cart
</button>
</form>
) : (
<p style="color: #e74c3c; font-weight: 500;">
This book is currently out of stock.
</p>
)}
<p style="margin-top: 1.5rem;">
<a href={routes.books.index.href()} class="btn btn-secondary">
Back to Books
</a>
</p>
</div>
</div>
</Layout>,
)
},
},
}
render()関数は、JSXをHTMLに変換し、適切なヘッダーを持つResponseを返します。
import type { Remix } from '@remix-run/dom'
import { renderToStream } from '@remix-run/dom/server'
import { html } from '@remix-run/fetch-router'
import { resolveFrame } from './frame.tsx'
export function render(element: Remix.RemixElement, init?: ResponseInit) {
return html(renderToStream(element, { resolveFrame }), init)
}
ステップ8: レスポンスの返却
-
ハンドラーが
Responseを返すResponse { status: 200, headers: Headers { 'Content-Type': 'text/html; charset=utf-8' }, body: ReadableStream<HTMLString> } -
ミドルウェアスタックを逆順に通過
-
loadAuth: レスポンスをそのまま返す -
logger: ログを出力してレスポンスを返す -
storeContext: レスポンスをそのまま返す
-
-
Router.dispatch()がResponseを返す -
Router.fetch()がResponseを返す -
createRequestListener()の中のsendResponse()が呼ばれる
ステップ9: Node.jsへのレスポンス送信
// sendResponse()の実行
// 1. ヘッダーを設定
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
})
// 2. ボディをストリーミング送信
for await (let chunk of response.body) {
res.write(chunk)
}
// 3. レスポンス完了
res.end()
ステップ10: クライアントへの配信
Node.jsがHTTPレスポンスとしてクライアントに送信:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: ...
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div style="display: grid; grid-template-columns: 300px 1fr; gap: 2rem;">
<!-- 書籍詳細のHTML -->
</div>
</body>
</html>
完全な呼び出しスタック
1. http.createServer() callback
↓
2. createRequestListener() wrapper
├─ createRequest(req, res)
│ └─ new Request(url, { method, headers, body })
└─ handler(request, client)
↓
3. router.fetch(request)
↓
4. router.dispatch(request)
├─ router.#parseRequest(request)
│ ├─ new RequestContext(request)
│ ├─ parseFormData(request) // GETなのでスキップ
│ └─ return context
│
├─ matcher.matchAll(context.url)
│ └─ RoutePattern('/books/:slug').match(url)
│ ├─ compilePattern() // 初回のみ
│ ├─ matcher.exec(pathname)
│ └─ return { url, params: { slug: 'the-great-gatsby' } }
│
└─ runMiddleware([storeContext, logger, loadAuth], context, handler)
↓
5. runMiddleware() - dispatch(0)
├─ storeContext(context, next)
│ ├─ context.storage.set('store', ...)
│ └─ next() → dispatch(1)
│ ↓
├─ logger(context, next)
│ ├─ console.log('→ GET /books/the-great-gatsby')
│ └─ next() → dispatch(2)
│ ↓
├─ loadAuth(context, next)
│ ├─ context.storage.set(USER_KEY, user)
│ └─ next() → dispatch(3)
│ ↓
└─ handler(context) // booksHandlers.show
├─ getBookBySlug('the-great-gatsby')
└─ render(<Layout>...</Layout>)
└─ new Response(html, { status: 200, headers: { ... } })
↑
return Response
↑
return Response (loadAuthを通過)
↑
console.log('← 200')
return Response (loggerを通過)
↑
return Response (storeContextを通過)
↑
6. router.dispatch() → Response
↓
7. router.fetch() → Response
↓
8. handler(request, client) → Response
↓
9. sendResponse(res, response)
├─ res.writeHead(200, headers)
├─ for await (chunk of response.body) { res.write(chunk) }
└─ res.end()
↓
10. クライアントにHTTPレスポンスを送信
まとめ
Remix 3は、Web標準APIを最大限に活用した、モダンで拡張性の高いフレームワークです。
エコシステムの強み
- 独立性: 各パッケージが独立して使用可能
- 組み合わせ可能: 必要なパッケージだけを選んで使用
- ゼロ依存: 外部依存がなく、セキュアで軽量
- クロスランタイム: Node.js、Bun、Deno、Cloudflare Workersで動作
- 型安全: TypeScriptで書かれ、完全な型推論をサポート
実装フローの特徴
- ストリーミングファースト: 全ての処理がストリーミングベース
- オニオンモデル: Koaスタイルのミドルウェアで柔軟な処理フロー
- 型安全なルーティング: パラメータが自動的に推論される
- メモリ効率: 大きなファイルもメモリにバッファせずに処理
- Web標準準拠: Request/Response APIで他のツールと相互運用可能
まとめ
以上が現状のRemix v3を読んでみた結果です。
どのように実装が今後進められていくのか、非常に楽しみですね。
最後まで読んでいただきありがとうございました。