アーキテクチャ

03. ルート定義からリクエストへ

ルート定義からリクエストの振り分け、ページのパラメーター検証までの実装を読み解きます。

描画先はPage、完全なパス、middleware、周囲のLayout/Loadingスコープをまとめたものです。 Effrontはリクエストを受け付ける前に描画先を組み立て、不正な宣言と、ルートには一致してもPageのパラメーター検証に失敗するURLを区別します。

サーバーが受け取る描画先から読む

Routes はPageとマウントした子Routesの木であり、動作中のHTTP matcherではありません。EFFRONT.make の実行時に、application/route-graph.ts の compileRouteGraph がこれを描画先の配列に変換します。

packages/core/src/application/route-graph.ts の抜粋
export type RouteScope<Services> = {
  readonly id: string;
  readonly layout: LayoutComponent<Services> | null;
  readonly loading: LoadingComponent<Services> | null;
};

export type CompiledDestination<Services> = {
  readonly middleware: ReadonlyArray<AnyMiddleware<Services>>;
  readonly page: PageImplementationState<Services>;
  readonly pattern: AbsolutePath;
  readonly scopes: ReadonlyArray<RouteScope<Services>>;
};

export type CompiledRouteGraph<Services> = readonly [
  CompiledDestination<Services>,
  ...Array<CompiledDestination<Services>>,
];

/:id にある子Pageを /items の下にマウントすると、/items/:id になります。 走査では親のスコープを引き継ぎ、子RoutesにLayoutかLoadingがある場合だけ新しいスコープを加えます。 パスをまとめるだけでは描画の境界は増えません。 スコープIDは宣言の scopeId とマウント先の接頭辞を組み合わせ、同じ宣言の複数回のマウントを区別します。

packages/core/src/application/route-graph.ts の抜粋
    for (const route of currentState.pages) {
      const pattern = joinRoutePaths(prefix, route.path);
      validateUnreservedPath(pattern);
      destinations.push(
        Object.freeze({ middleware, page: getPageState(route.page), pattern, scopes }),
      );
    }

    for (const mount of currentState.mounts) {
      visit(mount.routes, joinRoutePaths(prefix, mount.path), scopes, middleware);
    }
  };

ループは現在のノードのPage、次にマウント先を、それぞれの登録順でたどります。page と mount を交互に呼んでも、全体の呼び出し順は残りません。 これは照合の優先順位ではなく走査順です。照合はEffect HTTPが担当し、このコンパイラーは静的パスと動的パスを並べ替えません。

resolveRouteMiddleware は継承した鎖を残し、共通接頭辞を除いた現在の宣言の残りを加えます。 重複が残れば TypeError になります。 コンパイラーはrootのLayoutと少なくとも一つのPageも要求し、空でない描画先の配列をfreezeして返します。 リクエストはRoutesを再走査せず、この配列を使います。

URLから取り出した値をPageの入力につなぐ

application/routes.ts の page と mount は、既存の定義を変更せずに新しい定義を返します。RoutesDefinition はLayoutの有無、登録済みパス、照合上の形を保持し、後の追加を先の定義と照らし合わせて検査します。

MatchingPageParams はURLのパラメーター名とPage Schemaの Encoded 側のキーを比較します。/items/:id ではエンコード側のキーが id と過不足なく一致する必要があり、描画にはデコード後の Type を渡します。 変換で値や出力キーを変えても、URLのパラメーター名は変わりません。 静的パスにはパラメーターSchemaのないPageを使います。

実行時の登録処理は、文法、アプリケーションidentity、パラメーターとSchemaの有無の対応を検査します。 エンコード側の全キーを比べる型レベルの検査までは繰り返しません。

application/route-path.ts の ValidRoutePath と analyzeRoutePath が、型と実行時の文法検査を対にします。

  • パスは / で始め、:id のような名前付きセグメントを使います。末尾には *path のような名前付きcatch-allを置けます。
  • rootの / を除き、空セグメントと末尾のスラッシュは無効です。ドットセグメント、パラメーター名の重複、クエリー文字列、その他のワイルドカード形式も無効です。
  • マウントの接頭辞は静的である必要があります。子Routesは空でなく、アプリケーションidentityを共有する必要があります。

アプリケーションの組み立て時の失敗を見分ける

衝突検査は静的セグメントを小文字にし、パラメーター名を除いて照合上の形を比較します。 拒否するのは同じ形であり、一致範囲が重なるすべての組み合わせではありません。

  • /items/:id と /items/:slug は衝突します。
  • /About と /about は衝突します。
  • /manual/*path は、取得値が空になる /manual も予約します。
  • /items/new と /items/:id は形が異なるため共存できます。

マウントは接頭辞を結合した子のパスを検査するため、親に直接登録したPageとの衝突も検出します。joinRoutePaths は / を特別扱いし、余分なスラッシュを防ぎます。 型の検査に加え、実行時の衝突検査も TypeError を投げます。

validateUnreservedPath は結合後の最終パスを検査し、/_effront を保護します。 大文字小文字を問わず先頭セグメントが _effront の場合と、その値を取得できる動的な先頭セグメントを拒否します。 したがって最終パスの /:slug は無効ですが、/items/:slug はこの検査を通ります。 失敗は組み立て時に起き、リクエストURLの照合より前です。

ルートの照合とパラメーターの検証を分ける

server/application.ts の makeRouteLayer が、HttpRouter.add でGETとPOSTのhandlerを登録します。 URLの照合と取得値のデコードはEffect HTTPが担当します。 Effrontは * の取得値を宣言したcatch-all名へ移し、値がなければ空文字列を使います。 描画処理は HttpRouter.params を読み、PageのSchemaを適用します。

packages/core/src/server/application.ts の抜粋
    if (request.method !== "POST" && destination.page.paramsSchema !== null) {
      return yield* Schema.decodeEffect(destination.page.paramsSchema)(encodedParams).pipe(
        Effect.matchEffect({
          onFailure: () =>
            Effect.succeed(
              HttpServerResponse.empty({ status: 404, headers: DynamicResponseHeaders }),
            ),
          onSuccess: (value) => renderResponse({ _tag: "Decoded", value }),
        }),
      );
    }
    return yield* renderResponse({ _tag: "Encoded", value: encodedParams });
  });

非POSTリクエストでは、Schemaデコードの型付き失敗を、private, no-store が付いた本文なしの404に変換します。 成功時は Decoded パラメーターを渡し、Pageでの二重デコードを防ぎます。 たとえば /items/not-a-number は /items/:id に一致しても、IDを数値にデコードするSchemaには失敗します。

ルートの不一致やURL取得値の読み取り失敗は、このSchema handlerの外で起きます。 POSTもここを通らず、描画に Encoded パラメーターを渡し、Pageコンポーネントでデコードします。 そのため、GETのSchema失敗を404にする規則は、Server Functionの入力やrefreshの失敗には当てはまりません。

リクエスト処理 は選ばれた描画先のContextと寿命を追います。描画 はスコープをUIに変換し、Server Functions はPOST経路を追います。