Fresh 2 のミドルウェアを作ってみる

Fresh 2 についていつ出るんだろう。という話がよく聞くようになったこのタイミング。
公式がメッセージを出した。

An Update on Fresh

公開について、2025年第3四半期後半(おそらく9月)というアナウンスがあった。

今回は、Fresh 2 alpha版を使って新しいAPI向けミドルウェアを作ってみる。

参考

新APIのミドルウェアの構造

Fresh 2 のミドルウェアは、ExpressやHonoのようなAPIと紹介される。
Fresh 2 のプロジェクト作成初期状態では以下のようにmain.tsが準備される。

main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { App, fsRoutes, staticFiles } from "fresh";
import { define, type State } from "./utils.ts";

export const app = new App<State>();

app.use(staticFiles());

// this is the same as the /api/:name route defined via a file. feel free to delete this!
app.get("/api2/:name", (ctx) => {
const name = ctx.params.name;
return new Response(
`Hello, ${name.charAt(0).toUpperCase() + name.slice(1)}!`,
);
});

// this can also be defined via a file. feel free to delete this!
const exampleLoggerMiddleware = define.middleware((ctx) => {
console.log(`${ctx.req.method} ${ctx.req.url}`);
return ctx.next();
});
app.use(exampleLoggerMiddleware);

await fsRoutes(app, {
loadIsland: (path) => import(`./islands/${path}`),
loadRoute: (path) => import(`./routes/${path}`),
});

if (import.meta.main) {
await app.listen();
}

この中で、ポイントになるのは、以下の部分。

1
app.use(staticFiles());

のように、app.use(middleware)でミドルウェアを登録する。
または、ある特定パスについてすべて適用するなら、app.all(path, middleware)を使う。

実は、ミドルウェア実装のヒントは、既にmain.tsに記述がある。

main.ts
1
2
3
4
5
const exampleLoggerMiddleware = define.middleware((ctx) => {
console.log(`${ctx.req.method} ${ctx.req.url}`);
return ctx.next();
});
app.use(exampleLoggerMiddleware);

極端ところ、今回はこれの話である。

ミドルウェアの実装

型情報確認

define.middleware はミドルウェア実装の助けてくれる。
そのうえで、どういった型情報を持つのか見ておきたい。

fresh本体リポジトリ src/define.ts 抜粋
1
2
3
4
export interface Define<State> {
 // handlers pages などもあるが、ここでは省略
middleware<M extends Middleware<State>>(middleware: M): typeof middleware;
}
fresh本体リポジトリ src\middlewares\mod.ts 抜粋
1
2
3
4
5
export type Middleware<State> = MiddlewareFn<State> | MiddlewareFn<State>[];

export type MiddlewareFn<State> = (
ctx: FreshContext<State>,
) => Response | Promise<Response>;

このような内容になっている。

なので、パラメータを持つ/持たない場合で、ミドルウエアの型は以下ような形に別れる。

1
2
type nonParamsMiddleware = MiddlewareFn<State>;
type ParamsMiddleware = (...params: any[]) => MiddlewareFn<State>;

状態、パラメータを持たないミドルウェア

初期作成されるmain.tsに書かれたものがある種すべてだが改めて作成する。

middlewares\simple_logger.ts
1
2
3
4
5
import { define } from '../utils.ts';
export const simpleLoggerMiddleware = define.middleware((ctx) => {
console.log(`${new Date().toISOString()} ${ctx.req.method}: ${ctx.req.url}`);
return ctx.next();
});

このように定義できる。

その上で、main.tsに登録する。

main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { App, fsRoutes, staticFiles } from "fresh";
import { type State } from "./utils.ts";
import { simpleLoggerMiddleware } from "./middlewares/simple_logger.ts";

export const app = new App<State>();

app.use(staticFiles());
app.use(simpleLoggerMiddleware);

await fsRoutes(app, {
loadIsland: (path) => import(`./islands/${path}`),
loadRoute: (path) => import(`./routes/${path}`),
});

if (import.meta.main) {
await app.listen();
}

この様にすると、実行時に以下のようにコンソールに出力される。

1
2
3
4
5
6
7
8
9
$ deno task dev
Task dev deno run -A --watch=static/,routes/ dev.ts
Watcher Process started.

🍋 Fresh ready
Local: http://localhost:8000/

2025-06-08T03:01:14.438Z GET: http://localhost:8000/
2025-06-08T03:01:16.482Z GET: http://localhost:8000/service-worker.js

画像ファイルなどのリクエストが書けているのは、ミドルウェアの登録順序に問題がある。
以下のようにすれば、画像ファイル他のリクエストも記録される。

main.ts 抜粋
1
2
app.use(simpleLoggerMiddleware); // loggerを先に登録
app.use(staticFiles());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
deno task dev
Task dev deno run -A --watch=static/,routes/ dev.ts
Watcher Process started.

🍋 Fresh ready
Local: http://localhost:8000/

2025-06-08T03:03:50.967Z GET: http://localhost:8000/
2025-06-08T03:03:51.023Z GET: http://localhost:8000/_fresh/js/8d9593ced0a5d94217427917b9ef53a027a03243/fresh-runtime.js
2025-06-08T03:03:51.030Z GET: http://localhost:8000/styles.css
2025-06-08T03:03:51.036Z GET: http://localhost:8000/_fresh/js/8d9593ced0a5d94217427917b9ef53a027a03243/Counter.js
2025-06-08T03:03:51.039Z GET: http://localhost:8000/_fresh/js/8d9593ced0a5d94217427917b9ef53a027a03243/chunk-JXMI7VVF.js
2025-06-08T03:03:51.043Z GET: http://localhost:8000/logo.svg?__frsh_c=8d9593ced0a5d94217427917b9ef53a027a03243
2025-06-08T03:03:51.090Z GET: http://localhost:8000/favicon.ico
2025-06-08T03:03:52.574Z GET: http://localhost:8000/service-worker.js

ダウンストリームのミドルウエア

パラメータありミドルウエアの前に、ダウンストリームの加工について付言しておきたい。

Fresh 2 のミドルウェアは、ctx.next()を呼び出すことで、次のミドルウェアに処理を渡す。
このとき、ctx.next()は、Promiseを返す。
サンプルの例では、return ctx.next(); をよく見かける。
実際返ってくるのは、Promise<Response> なので、これを加工してクライアントに返すことができる。

middlewares/downstream.ts
1
2
3
4
5
6
7
8
import { define } from '../utils.ts';
export const downstreamMiddleware = define.middleware(async (ctx) => {
const res = await ctx.next();

res.headers.set('server', 'fresh');

return res;
});

それぞれのレスポンスを作っている処理の先でレスポンスヘッダーを加工するのではなく、集約して加工ができる。

状態/パラメータを持つミドルウェア

状態やパラメータを持つミドルウェアは、以下のように定義できる。
ここではパラメータで与えられた値をもとにしたレートリミットのミドルウェアを作成する。

middlewares/simple_late_limit.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { define } from "../utils.ts";

export function simpleLateLimit(limitPerMinute: number) {
const requests = new Map<string, number>();

const getIpWithTime = (ip: string) => {
return `${ip}-${Math.floor(Date.now() / 60000)}`
}

return define.middleware(async (ctx) => {
const hostname = (ctx.info.remoteAddr as { hostname: string }).hostname;
const ip = ctx.req.headers.get("X-Forwarded-For") || ctx.req.headers.get("X-Real-IP") || hostname;
if (!ip) {
return new Response("IP address not found", { status: 400 });
}
const key = getIpWithTime(ip);
requests.set(key, (requests.get(key) || 0) + 1);

if((requests.get(key)||0) > limitPerMinute) {
return new Response("Rate limit exceeded", {
status: 429,
headers: {
"Content-Type": "text/plain",
"X-RateLimit-Limit": String(limitPerMinute),
},
});
}

const res = await ctx.next();
res.headers.set("X-RateLimit-Remaining", String(Math.max(0, limitPerMinute - (requests.get(key) || 0))));

return res;
});
}

ctx.info.remoteAddr.hostname が実際には生えているのだが、定義に無いようで暫定対応した。

以下のようにappに登録できる。

main.ts
1
app.use(simpleLateLimit(100));

これを使うと、アクセス元のIPアドレスごとに、1分間に100回までのリクエストを許可する。
それを超えると、429 Too Many Requestsのレスポンスが返される。
またダウンストリームで、残りアクセス可能回数をレスポンスヘッダーに追加する。
このようにパラメータに基づき、ミドルウェアが状態(アクセス回数)を持ちレートリミットのミドルウェアを作成できる。

メソッドを公開するミドルウェア

最後に、ミドルウェアがメソッドを公開し、個別ハンドラーから使わせるケースを紹介する。

以下のようにミドルウェアと共にミドルウェアの公開するメソッドについての型を定義する。

middlewares\logger_with_id.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { define } from "../utils.ts";

export interface LoggerStatus {
logger: {
log: (message: string) => void;
}
}

export const loggerWithIdMiddleware = define.middleware((ctx) => {

const logId = crypto.randomUUID();
const logMessage = (message: any) => `${new Date().toISOString()} [${logId}] ${message.toString()}`;

ctx.state.logger = {
log: (message: string) => console.log(logMessage(message)),
};

return ctx.next();
});

定義したLoggerStatusを含めたStateをcreateDefineの型引数に指定することで、コンテキストのstate以下にloggersがあることを全体に伝搬されている。

utils.ts
1
2
3
4
5
6
7
import { createDefine } from "fresh";
import { LoggerStatus } from './middlewares/logger_with_id.ts';

// deno-lint-ignore no-empty-interface
export interface BaseState {}
export interface State extends BaseState, LoggerStatus {}
export const define = createDefine<State>();

ミドルウェアをappへ登録。

main.ts
1
app.use(loggerWithIdMiddleware);

個別ハンドラーとなる、ページでは以下のように使える。

routes\index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useSignal } from "@preact/signals";
import { define } from "../utils.ts";
import Counter from "../islands/Counter.tsx";

export default define.page(function Home(ctx) {
const count = useSignal(3);

console.log(ctx.state);

ctx.state.logger.log("Rendering Home page");


return (
<div class="px-4 py-8 mx-auto fresh-gradient">
{/* 省略 */}
</div>
);
});

実行すると以下のようにコンソールに出力される。

1
2
3
4
$ deno task dev

{ logger: { log: [Function: log] } }
2025-06-08T04:28:37.439Z [768fd1cf-580d-4380-b8f7-b4a99af755b8] Rendering Home page

ミドルウェアが提供するメソッドを使えたことを確認できる。
この型定義は、define.~ を使い定義される全体に伝搬するので、リクエスト到達からレスポンスまでの間、どこでも利用できる。

例えば、以下のように定義したミドルウェアでも使える。

routes\api\hello.ts
1
2
3
4
5
6
import { define } from "../utils.ts";
export const useLoggerWithIdMiddleware = define.middleware(async (ctx) => {
const res = await ctx.next();
ctx.state.logger.log(`Response: ${res.status} ${res.statusText}`);
return res;
});
main.ts
1
2
app.use(loggerWithIdMiddleware);
app.use(useLoggerWithIdMiddleware);

起動してアクセス。

1
2
3
4
$ deno task dev

2025-06-08T04:44:35.523Z [aa08d458-1421-49a6-8d30-cfa8607cf6e0] Rendering Home page
2025-06-08T04:44:35.528Z [aa08d458-1421-49a6-8d30-cfa8607cf6e0] Response: 200

というように、同一のIDによるログ出力が行われていることがわかる。
(OTELが標準でサポートされるとこういった配慮は不要になる可能性はあるのだが。)


というところで、Fresh 2 のミドルウェアの作成方法について紹介した。
createDefine とそれによる型情報の伝搬によりFresh 1よりもいささか書きやすくなった様に感じられる。

今回触れない各種ページの定義が、Fresh 1 とは大きく変わっているようで、1系から使っている場合にはそれなりに作業量を要するように見える。
またこれも紹介したい。

では。