Registry / RepositoryTypeScriptRustPython
Rollpie
ReadmeFiles
Versions
Info
Download
0.1.280.1 KB2026-09-140.1.167.6 KB2026-09-140.1.064.9 KB2026-09-14
Version
0.1.0
Copyright
Rollpie, Irohabook
Publisher
math
Published
2026-09-14
Size
64.9 KB
Downloads
0
Checksum
3f5a7260ef28c5f7ca3540b87faa9176bdf8039ad720f809e49a0f7117b8528b
Dependencies
None

README.ja.md

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# mon

axum の上に、サーバーの土台を敷くライブラリです。
JSON で返す共通のエラー、失敗も JSON になる extractor、HTML の組み立て、別サーバへの中継、
セッション(置き場は 4 通りから選ぶ)をまとめて持ちます。

保存先は SQLite で、sqlx から使います。

英語の説明は [README.md](./README.md) にあります。

## 使う

**mon はパスを 1 つも取りません。** どのパスに何を置くか、そもそも置くかどうかは使う側が決めます。
`route::wrap` はミドルウェアを掛けて状態を入れるだけで、ルートは置きません。

```
use axum::Router;
use axum::routing::get;
use mon::{AppState, Conf, conf, database, page, relay, route, session};
use std::sync::Arc;

let conf = conf::load()?;                        // Conf を直接組み立ててもよい
let database = database::connect(&conf.database_url).await?;
let store = session::store(&conf, &database);
session::prepare(&store).await?;

// 名前も置くかどうかも、ここで決める
let routes = my_routes()
    .route("/health", get(route::health))
    .nest("/api/session", session::router())
    .nest("/relay", relay::router())
    .nest_service("/static", route::assets(&conf))
    .fallback(page::not_found);

let state = AppState {
    database,
    client: relay::client(&conf)?,
    session: store,
    conf: Arc::new(conf),
};

// ConnectInfo を入れると、CLIENT_IP_HEADER がないときに TCP の相手を使える
let app = route::wrap(routes, state).into_make_service_with_connect_info::<SocketAddr>();
axum::serve(listener, app).await?;
```

`/health` を診療案内のページに使いたいなら、そう置けばよいだけです。mon が横取りしません。
`tests/collision.rs` で確かめています。

固定の文字列は、パス以外でも作りません。クッキー名は `SESSION_COOKIE`、
セッションの表の名前は `SESSION_TABLE`、置き場のディレクトリは `SESSION_DIRECTORY`、
リクエスト ID のヘッダ名は `REQUEST_ID_HEADER` で決めます。どれも空にすれば使いません。

表は使う側が用意します。mon が自分で作るのは、`sqlite` を選んだときの `session` 表だけで、
`session::prepare` が `create table if not exists` で用意します。
使う側のマイグレーションには混ざりません。

## 動かす

```
cargo run                       # 土台だけ。http://localhost:3000
cargo run --example todo        # /api/todos と一覧のページを載せた例
RUST_LOG=debug cargo run        # ログを詳しく
PORT=8080 cargo run             # ポート変更
cargo test                      # テスト
cargo build --release           # LTO と strip 済み
```

edition 2024 なので rustc 1.85 以上が要ります。

## mon が渡す部品

置く場所は使う側が決めます。下のパスは、上の例で置いた場合のものです。

- `route::health` - `{"status": "ok"}` を返すだけ。DB は見ない
- `session::router()` - 中の `GET` で読み、`POST` で書き、`DELETE` で捨てる
- `relay::router()` - `/` とその下すべてを中継する
- `route::assets(&conf)` - 静的ファイルを配る service
- `page::not_found` - 当たらなかったときの HTML

エラーはすべて `{"error": "..."}` の JSON です。panic も時間切れも同じ形にそろえています。
例外は 3 つです。`page::not_found` は HTML を返します。
`route::assets` の 404 と、本文が上限を超えたときの 413 は text/plain です。
どちらも tower-http がそのまま返すものです。

## 環境変数

どれも省略できます。読み取りは conf.rs の 1 箇所にまとめています。

省略すれば既定値を使いますが、**書いてあって読めないときは起動しません**。
黙って既定値へ落とすと、指定した覚えのないポートで上がり、プロセスは動いているのに
前段から見ると 502、という一番わかりにくい形になるためです。

```
PORT=800o             → PORT=800o を数として読めません(invalid digit found in string)
PORT=0                → PORT=0 は 1 以上にしてください
CORS_CREDENTIALS=ture → CORS_CREDENTIALS=ture は true か false で書いてください
```

数の設定は 0 を許しません。`PORT=0` は OS が空きポートを勝手に選んでしまうためです。
0 を許すのは 2 つだけです。`CORS_MAX_AGE` はプリフライトを保存させない、
`TRUSTED_PROXY` は読み飛ばさない、という意味になります。

- `PORT` - 待ち受けポート。既定 3000
- `DATABASE_URL` - SQLite の場所。既定 `sqlite:mon.db`
- `STATIC_DIRECTORY` - 静的ファイルの置き場。既定 `static`
- `STATIC_CACHE_CONTROL` - 静的ファイルに付ける Cache-Control。既定 `no-cache`。空なら付けない
- `RELAY_ORIGIN` - 中継先の起点。既定 `http://localhost:6060`
- `CORS_ORIGIN` - 許可するオリジンをコンマ区切りで書く。空なら全部許可する
- `CORS_METHOD` - 許可するメソッドをコンマ区切りで書く。空なら既定に任せる
- `CORS_HEADER` - 許可するリクエストヘッダをコンマ区切りで書く。空なら既定に任せる
- `CORS_EXPOSE_HEADER` - JavaScript から読ませるレスポンスヘッダ。空なら既定に任せる
- `CORS_MAX_AGE` - プリフライトの結果をブラウザに保存させる秒数。既定 3600
- `CORS_CREDENTIALS` - Cookie 付きのリクエストを許すか。既定 false
- `SESSION_STORE` - セッションの置き場。`none` `memory` `file` `sqlite`。既定 sqlite
- `SESSION_DIRECTORY` - file のときの置き場。既定 `session`
- `SESSION_TABLE` - sqlite のときの表の名前。既定 `session`
- `SESSION_COOKIE` - クッキー名。既定 `sid`
- `SESSION_MAX_AGE` - セッションを保つ秒数。既定 2592000(30 日)
- `SESSION_SECURE` - クッキーに Secure を付けるか。既定 false。本番では立てる
- `SESSION_SAME_SITE` - `lax` `strict` `none`。既定 lax
- `REQUEST_ID_HEADER` - リクエスト ID を入れるヘッダ名。既定 `x-request-id`。空なら付けない
- `CLIENT_IP_HEADER` - 相手の IP を読むヘッダ名。既定は空。空なら TCP の相手を使う
- `TRUSTED_PROXY` - そのヘッダを右から何個読み飛ばすか。既定 0
- `CONTENT_SECURITY_POLICY` - 応答に付ける CSP。既定は空。空なら付けない
- `BODY_LIMIT` - リクエストボディの上限バイト数。既定 2 MiB
- `REQUEST_TIMEOUT` - リクエスト 1 本にかける上限秒数。既定 10
- `RELAY_TIMEOUT` - 中継先の応答を待つ上限秒数。既定 3

## CORS

`CORS_ORIGIN` が空ならどのオリジンからでも通し、書いてあればそのオリジンだけ通します。
プリフライトの OPTIONS には CorsLayer が自前で答えるので、ハンドラは書きません。

`CORS_ORIGIN` に書く値は、ブラウザが送ってくる Origin と 1 字も違ってはいけません。
`scheme://host` の形で、ポートは付けてよく、パスと末尾のスラッシュは付けません。小文字で書きます。
形が違えば起動しません。書いたのに効かない、という形で残るのが一番わかりにくいためです。
`CORS_ORIGIN=*` も止めます。全部許可するなら空にしてください。

`CORS_CREDENTIALS=true` にすると Cookie 付きのリクエストが通ります。
このときオリジンにもメソッドにもヘッダにも `*` を返せない、という決まりが CORS にあります。
`CORS_ORIGIN` を空のまま credentials を立てた場合は、警告を出して credentials だけ落とします。
メソッドとヘッダは、指定がなければ次を返します。

- メソッド: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
- リクエストヘッダ: content-type, authorization
- 読ませるレスポンスヘッダ: x-request-id

credentials を使わないときは、この 3 つはどれも `*` です。

## 相手の IP

nginx のような中継の後ろでは、TCP の相手は中継そのものです。
本当の相手は `X-Forwarded-For` や `X-Real-IP` で渡ってきます。
読むヘッダは `CLIENT_IP_HEADER`、右から何個読み飛ばすかは `TRUSTED_PROXY` で決めます。

ヘッダの左端は相手が自由に書けます。
nginx の `$proxy_add_x_forwarded_for` は自分が見た IP を右へ足すので、信用できるのは右からです。
nginx が 1 段なら `CLIENT_IP_HEADER=x-forwarded-for` だけで、`TRUSTED_PROXY` は 0 のままです。
前に CDN が入ったら 1 にします。

`CLIENT_IP_HEADER` を書かないかぎり、ヘッダは一切見ません。
設定を忘れたまま詐称された値を採ることはありません。

ハンドラからはこう使います。

```
async fn where_from(ClientIp(address): ClientIp) -> String {
    address.map(|value| value.to_string()).unwrap_or_default()
}
```

TCP の相手を使うには、`axum::serve` に `into_make_service_with_connect_info::<SocketAddr>()` を
渡してください。渡していなければヘッダだけで決め、それも取れなければ `None` になります。
決まった値はログの span にも入ります。

## セッション

クッキーには id だけを入れ、中身は置き場に保存します。
置き場は `SESSION_STORE` で選びます。`sqlite` は mon.db の `session` 表、`file` は `<id>.json`、
`memory` はプロセスの中、`none` はセッションを使いません。

memory は再起動で消え、プロセスを 2 つに増やすと片方でセッションが切れます。dev 用です。

**発行は遅らせます。** 読むだけの相手には何も作らず、`Set-Cookie` も出しません。
ハンドラがセッションに書いたときに初めて id を作り、保存し、クッキーを出します。
そうしないと、初めて来た相手 1 人につき 1 件、クローラの分まで溜まっていきます。

id は 32 バイトの乱数を 16 進で書いたものです。判定は置き場にあるかどうかなので、署名はしません。
相手が送ってきた id は、置き場に実物があるときだけ使います。覚えのない id はそのまま採らず、
新しく作り直します。

`SESSION_SECURE` の既定は false です。dev の `http://localhost` で動かすためで、
本番では `SESSION_SECURE=true` を立ててください。
`SESSION_SAME_SITE=none` は Secure がないとブラウザが捨てるので、その組み合わせでは起動しません。

期限切れは、読んだときと、1 時間ごとの掃除で消します。
期限の半分を過ぎたセッションは、書き換えがなくても保存し直して先へ延ばします。

`Set-Cookie` は保存し直したときだけ出します。読むだけの応答には付けません。
毎回付けると、静的ファイルの応答まで共有キャッシュや CDN に載らなくなるためです。

ログインのように相手の立場が変わったところでは、`session.renew().await` を呼んで id を替えます。
中身は残り、古い id は置き場から消えます。
呼ばないと、攻撃者が自分で取った id を相手のブラウザに置き、そのまま認証を通せます。
mon は覚えのない id を採らないので捏造は防げますが、正規に取った id を置かれる筋は塞げません。

ハンドラからはこう使います。

```
async fn login(session: Session) -> ... {
    session.write().await.language = Some("ja".to_owned())
}
```

`write()` を呼んだ時点で保存する印が立ちます。`read()` は見るだけで、保存しません。
セッションに入れる項目は session.rs の `SessionData` に足します。JSON で保存するので、
足しても置き場の側は変わりません。

## ログ

`log::start(default)` を呼ぶと、標準出力へ流します。書き込みは専用のスレッドへ逃がすので、
出力先が詰まってもリクエストを捌く側は止まりません。

```
// guard は終わりまで持つ。落とすと書き出しのスレッドが止まり、まだ書けていない分は消える
let _log = log::start("info,tower_http=debug");
```

レベルは `RUST_LOG` があればそちらが勝ちます。書き込みが追いつかないときは、待たずに落とします。
捌く側を止めないためです。たまり場は 128,000 行で、あふれた分は消えます。
何本消えたかは `dropped()` で聞けます。こちらから聞かないかぎり、誰も教えてくれません。

ライブラリが勝手に入れるものではないので、呼ぶかどうかは使う側が決めます。
自分で組むなら、この関数を使わずに `tracing_appender::non_blocking` を直接使ってください。

## 中継

`/relay/` より後ろのパスとクエリを `RELAY_ORIGIN` につないで転送します。
メソッドとヘッダとボディはそのまま送り、返ってきた status とヘッダとボディをそのまま返します。
接続そのものに関わるヘッダ(connection、host、transfer-encoding など)だけは送らず、返しもしません。
リダイレクトは追いません。中継先が返した 302 は、行き先ごとそのまま相手へ渡します。
追いかけると、相手には最終の応答だけが届き、元の 302 が消えるためです。

`X-Forwarded-For`・`-Proto`・`-Host` は、ないときだけ足します。
あるなら前段が付けたものなので触りません。書き換えると、中継先が数える位置がずれるためです。
効くのは mon が最前段のときだけです。
host は転送しないので、これがないと中継先は公開しているホストを知る手段がありません。
中継先の様子で返す番号を分けます。つながらなければ 502、つながったが返事がなければ 504 です。
待ち時間は「つなぐまで」と「次の一片が届くまで」の 2 つに掛けます。
全体に掛けると、大きな応答を流している途中で切れてしまうためです。

## 静的ファイル

`route::assets(&conf)` は `STATIC_DIRECTORY` を配る service です。
`STATIC_CACHE_CONTROL` の値を付けます。既定は `no-cache` で、空にすれば付けません。

何も付けないと、ブラウザは Last-Modified からの経過時間で勝手に期限を決め、その間は聞きにきません。
名前の変わらないファイルを置き換えても、古いものが出ます。
`no-cache` は保存しないという意味ではなく、使う前に必ず聞きにこさせる、という意味です。
中身が変わっていなければ 304 が返るので、往復 1 回で済みます。

ファイル名にハッシュを入れているなら、`max-age=31536000, immutable` にしてください。

## CSP

`CONTENT_SECURITY_POLICY` に書いた値を、すべての応答の Content-Security-Policy に付けます。
既定は空で、そのときは付けません。
読み込んでよい先はアプリごとに違うので、mon は既定値を持ちません。

```
CONTENT_SECURITY_POLICY="default-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
```

ハンドラが自分で付けていれば、そちらを残します。
ページごとに変えたいときは、ハンドラ側で付けてください。

`frame-ancestors` は X-Frame-Options の後継です。こちらに書けば、別途 X-Frame-Options は要りません。

`html::document` は `<style>` にスタイルを直書きします。
`default-src 'self'` だけを書くと `page::not_found` のスタイルが効かなくなるので、
`style-src 'unsafe-inline'` を足すか、自分の 404 を置いてください。

## 保存

表は使う側が用意します。mon が作るのはセッションの表だけで、`session::prepare` が
`create table if not exists` で用意します。マイグレーションの仕組みは持ちません。
ライブラリがそれを持つと、使う側のマイグレーションに mon の表が混ざるためです。

表の名前も `SESSION_TABLE` で決められます。既定は `session` ですが、同じ名前の表を
すでに持っているなら変えてください。

`sqlx::query!` のコンパイル時マクロは使いません。
ビルドのたびに DB への接続か `.sqlx/` のキャッシュが要るためです。
代わりに実行時の `query_as` と `FromRow` の derive を使います。

## 構成

```
src/
├── lib.rs       公開する入口
├── main.rs      土台だけを動かす bin
├── conf.rs      設定。Conf::default と、環境変数から読む load
├── state.rs     全ハンドラで共有する状態
├── database.rs  SQLite のプール
├── route.rs     ルーティングとミドルウェア(テストもここ)
├── session.rs   セッション(クッキーと置き場)
├── error.rs     共通エラー型
├── extract.rs   Json / Path / Query
├── html.rs      HTML の組み立て
├── ip.rs        相手の IP(ヘッダをどこまで信用するか)
├── log.rs       ログの用意(呼ぶかどうかは使う側)
├── page.rs      当たらなかったときの HTML(置くかどうかは使う側)
└── relay.rs     別サーバへの中継

examples/
└── todo.rs      /api/todos と一覧のページを載せた例

tests/
├── collision.rs mon がパスを取らないこと
└── relay.rs     中継。上流をテストの中で立てて確かめる
```

## 試す

`cargo run --example todo` を動かしてから。

```
curl -X POST localhost:3000/api/todos -H 'content-type: application/json' -d '{"title":"買い物"}'
curl localhost:3000/api/todos
curl -X PATCH localhost:3000/api/todos/<id> -H 'content-type: application/json' -d '{"done":true}'
curl -i -X DELETE localhost:3000/api/todos/<id>
curl -i -X POST localhost:3000/api/session \
	-H 'content-type: application/json' -d '{"language":"ja"}'
```

## 書き方

rustfmt はかけません。
関数どうしの間の 2 行の空行を rustfmt は 1 行に潰します。
`{key: value}` の波括弧内から空白を消す設定もありません。
どちらも表現できないので、手で揃えます。

## ライセンス

MIT と Apache-2.0 の二択です。好きなほうを選んでください。

- [LICENSE-MIT](./LICENSE-MIT)
- [LICENSE-APACHE](./LICENSE-APACHE)