@senrijs/senri 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +331 -2
  3. package/dist/adapters/node/index.d.ts +14 -0
  4. package/dist/adapters/node/index.js +152 -0
  5. package/dist/adapters/serve/index.d.ts +28 -0
  6. package/dist/adapters/serve/index.js +137 -0
  7. package/dist/adapters/static/index.d.ts +9 -0
  8. package/dist/adapters/static/index.js +120 -0
  9. package/dist/src/app.d.ts +49 -0
  10. package/dist/src/app.js +375 -0
  11. package/dist/src/body.d.ts +3 -0
  12. package/dist/src/body.js +45 -0
  13. package/dist/src/compose.d.ts +22 -0
  14. package/dist/src/compose.js +95 -0
  15. package/dist/src/connection.d.ts +3 -0
  16. package/dist/src/connection.js +49 -0
  17. package/dist/src/context.d.ts +60 -0
  18. package/dist/src/context.js +254 -0
  19. package/dist/src/cookie.d.ts +13 -0
  20. package/dist/src/cookie.js +61 -0
  21. package/dist/src/dev/dashboard/access.d.ts +6 -0
  22. package/dist/src/dev/dashboard/access.js +46 -0
  23. package/dist/src/dev/dashboard/index.d.ts +27 -0
  24. package/dist/src/dev/dashboard/index.js +51 -0
  25. package/dist/src/dev/dashboard/links.d.ts +3 -0
  26. package/dist/src/dev/dashboard/links.js +8 -0
  27. package/dist/src/dev/dashboard/log.d.ts +20 -0
  28. package/dist/src/dev/dashboard/log.js +53 -0
  29. package/dist/src/dev/dashboard/mask.d.ts +4 -0
  30. package/dist/src/dev/dashboard/mask.js +43 -0
  31. package/dist/src/dev/dashboard/record.d.ts +37 -0
  32. package/dist/src/dev/dashboard/record.js +50 -0
  33. package/dist/src/dev/dashboard/routes.d.ts +7 -0
  34. package/dist/src/dev/dashboard/routes.js +121 -0
  35. package/dist/src/dev/dashboard/sse.d.ts +7 -0
  36. package/dist/src/dev/dashboard/sse.js +73 -0
  37. package/dist/src/dev/editor-url.d.ts +14 -0
  38. package/dist/src/dev/editor-url.js +61 -0
  39. package/dist/src/dev/editor.d.ts +6 -0
  40. package/dist/src/dev/editor.js +14 -0
  41. package/dist/src/dev/error-page.d.ts +3 -0
  42. package/dist/src/dev/error-page.js +69 -0
  43. package/dist/src/dev/fallback.d.ts +3 -0
  44. package/dist/src/dev/fallback.js +40 -0
  45. package/dist/src/dev/hints.d.ts +6 -0
  46. package/dist/src/dev/hints.js +82 -0
  47. package/dist/src/dev/html.d.ts +1 -0
  48. package/dist/src/dev/html.js +14 -0
  49. package/dist/src/dev/index.d.ts +6 -0
  50. package/dist/src/dev/index.js +11 -0
  51. package/dist/src/dev/messages.d.ts +2 -0
  52. package/dist/src/dev/messages.js +6 -0
  53. package/dist/src/dev/not-found-page.d.ts +7 -0
  54. package/dist/src/dev/not-found-page.js +33 -0
  55. package/dist/src/dev/page-data.d.ts +95 -0
  56. package/dist/src/dev/page-data.js +0 -0
  57. package/dist/src/dev/page.generated.d.ts +1 -0
  58. package/dist/src/dev/page.generated.js +3 -0
  59. package/dist/src/dev/pages.d.ts +4 -0
  60. package/dist/src/dev/pages.js +59 -0
  61. package/dist/src/dev/render.d.ts +5 -0
  62. package/dist/src/dev/render.js +42 -0
  63. package/dist/src/dev/runtime.d.ts +3 -0
  64. package/dist/src/dev/runtime.js +45 -0
  65. package/dist/src/dev/server-timing.d.ts +6 -0
  66. package/dist/src/dev/server-timing.js +45 -0
  67. package/dist/src/dev/source.d.ts +20 -0
  68. package/dist/src/dev/source.js +106 -0
  69. package/dist/src/dev/stack.d.ts +12 -0
  70. package/dist/src/dev/stack.js +93 -0
  71. package/dist/src/dev/suggest.d.ts +12 -0
  72. package/dist/src/dev/suggest.js +95 -0
  73. package/dist/src/errors.d.ts +23 -0
  74. package/dist/src/errors.js +136 -0
  75. package/dist/src/group.d.ts +38 -0
  76. package/dist/src/group.js +83 -0
  77. package/dist/src/health.d.ts +9 -0
  78. package/dist/src/health.js +40 -0
  79. package/dist/src/index.d.ts +13 -0
  80. package/dist/src/index.js +10 -0
  81. package/dist/src/logger.d.ts +18 -0
  82. package/dist/src/logger.js +110 -0
  83. package/dist/src/matcher.d.ts +16 -0
  84. package/dist/src/matcher.js +86 -0
  85. package/dist/src/middleware/body-limit.d.ts +6 -0
  86. package/dist/src/middleware/body-limit.js +47 -0
  87. package/dist/src/middleware/cors.d.ts +10 -0
  88. package/dist/src/middleware/cors.js +79 -0
  89. package/dist/src/middleware/response.d.ts +2 -0
  90. package/dist/src/middleware/response.js +28 -0
  91. package/dist/src/middleware/secure-headers.d.ts +19 -0
  92. package/dist/src/middleware/secure-headers.js +46 -0
  93. package/dist/src/router.d.ts +30 -0
  94. package/dist/src/router.js +177 -0
  95. package/dist/src/runtime.d.ts +4 -0
  96. package/dist/src/runtime.js +49 -0
  97. package/dist/src/validate.d.ts +47 -0
  98. package/dist/src/validate.js +87 -0
  99. package/package.json +74 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ibuki-hum4
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,332 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Senrijs/Senri/main/.github/assets/senri-wordmark-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/Senrijs/Senri/main/.github/assets/senri-wordmark-light.svg" alt="Senri" width="320">
5
+ </picture>
6
+ </p>
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ <p align="center">
9
+ Web 標準の <code>Request</code> / <code>Response</code> だけで書かれた、小さな Web フレームワーク。<br>
10
+ Bun・Deno・Node.js・Cloudflare Workers で、同じコードが動きます。
11
+ </p>
12
+
13
+ ```ts
14
+ import { createApp } from "@senrijs/senri";
15
+ import { serve } from "@senrijs/senri/serve";
16
+
17
+ const app = createApp();
18
+
19
+ app.get("/hello", () => "こんにちは!"); // 文字列 → text/plain
20
+ app.get("/users/:id", ({ id }) => ({ id })); // オブジェクト → JSON(id の型はパスから推論)
21
+
22
+ serve(app); // Bun でも Deno でも Node.js でも
23
+ ```
24
+
25
+ - **返した値がレスポンスになる。** 文字列はテキスト、オブジェクトは JSON。`Response` もそのまま返せます
26
+ - **型はパスから。** `/users/:id` と書けば、ハンドラーの `id` に型が付きます。[Standard Schema](https://standardschema.dev)(Zod・Valibot・ArkType など)で、パラメーター・クエリ・ボディも確かめられます
27
+ - **開発モードが親切。** エラーページ(該当コード・ヒント・エディタで開く)、404 の「もしかして」、リクエストを玉ねぎのように見られるダッシュボード(`/_senri`)、層ごとの `Server-Timing`
28
+ - **どこでも動く。** Web 標準の API だけで書いていて、`eval` も `new Function` も使いません
29
+
30
+ ## インストール
31
+
32
+ ```bash
33
+ npm install @senrijs/senri # Node.js / Bun
34
+ bun add @senrijs/senri
35
+ deno add jsr:@senrijs/senri
36
+ ```
37
+
38
+ Node.js は 22 以降で動きます。
39
+
40
+ ## 起動のしかた
41
+
42
+ `@senrijs/senri/serve` の `serve()` は、ランタイムを自動で判別して起動します。
43
+
44
+ ```ts
45
+ import { serve } from "@senrijs/senri/serve";
46
+
47
+ const server = await serve(app); // 起動すると、URL と登録されているルートの一覧を表示する
48
+ server.url; // 例: "http://localhost:3927"
49
+ await server.stop(); // 処理中のリクエストを待ってから止める
50
+ ```
51
+
52
+ ポートは次の順に、最初に見つかったものを使います。
53
+
54
+ 1. `serve(app, { port: 8080 })` で明示したポート(`0` なら空いているポートを自動で選ぶ)
55
+ 2. 環境変数 `PORT`(数字でない、範囲外などのときは、黙って別のポートで待ち受けずにエラーにします)
56
+ 3. デフォルトの `3927`
57
+
58
+ Deno では、環境変数を読む許可(`--allow-env=PORT` など)があるときだけ `PORT` を読みます。許可がなければ、プロンプトを出さずにデフォルトを使います。
59
+
60
+ ランタイムの API を直接使うこともできます。
61
+
62
+ ```ts
63
+ Bun.serve({ port: 3927, fetch: app.fetch }); // Bun
64
+ Deno.serve({ port: 3927 }, app.fetch); // Deno
65
+
66
+ import { serve } from "@senrijs/senri/node"; // Node.js
67
+ serve(app, { port: 3927 });
68
+ ```
69
+
70
+ ### Cloudflare Workers
71
+
72
+ アプリをそのまま `export default` します。`c.env` と `c.executionCtx` が使えます。
73
+
74
+ ```ts
75
+ import { createApp } from "@senrijs/senri";
76
+
77
+ type Env = { GREETING: string };
78
+ const app = createApp<{ Bindings: Env }>();
79
+
80
+ app.get("/", (_, c) => {
81
+ c.executionCtx.waitUntil(logSomewhere());
82
+ return c.env.GREETING; // string
83
+ });
84
+
85
+ export default app;
86
+ ```
87
+
88
+ - 変数の型も決めるときは `createApp<{ Bindings: Env; Variables: { user: User } }>()` と書きます。変数だけなら、今までどおり `createApp<{ user: User }>()` です
89
+ - `c.ip` は、Cloudflare が付ける `CF-Connecting-IP` を読みます(Workers で動いているときだけ)
90
+ - 開発モードのダッシュボードは作りません(`/_senri` はふつうの 404)。エラーページは出ますが、該当コードは出ません
91
+ - 静的ファイルは `@senrijs/senri/static` ではなく、Workers の [Static Assets](https://developers.cloudflare.com/workers/static-assets/) で配ってください
92
+
93
+ ### 止め方(graceful shutdown)
94
+
95
+ `serve()` は SIGTERM / SIGINT を受けると、次の順に止まります。
96
+
97
+ 1. `health()` の `/readyz` を 503 にする(ロードバランサーが新しいリクエストを送ってこないように)
98
+ 2. 新しい接続を受けるのをやめ、処理中のリクエストが終わるのを待つ(`shutdownTimeout`、既定 10 秒。過ぎたら残りの接続を切る)
99
+ 3. `onShutdown` を呼んでから終わる。待っている間にもう一度シグナルを受けたら、すぐに終わる
100
+
101
+ ```ts
102
+ serve(app, {
103
+ shutdownTimeout: 30_000,
104
+ onShutdown: () => db.close(),
105
+ });
106
+ ```
107
+
108
+ ランタイムの既定どおりにしたいときは `gracefulShutdown: false` にします。
109
+
110
+ ## ルート
111
+
112
+ ```ts
113
+ app.get("/users/me", () => "それは自分です"); // 静的なセグメントが優先(登録順は関係ない)
114
+ app.get("/users/:id", ({ id }) => `user ${id}`);
115
+ app.post("/users", async (_, c) => c.json(await c.body(), 201));
116
+ app.on(["PUT", "PATCH"], "/users/:id", () => "updated");
117
+ app.all("/any", () => "どのメソッドでも");
118
+ app.routes({ "GET /ping": () => "pong" }); // 表の形でもまとめて登録できる
119
+ ```
120
+
121
+ - `app.get` / `post` / `put` / `patch` / `delete` / `head` / `options` / `all`、任意のメソッドは `app.on(method | method[], path, handler)`
122
+ - HEAD は、登録していなければ GET のルートで処理してボディを捨てます。OPTIONS は、登録していなければ 204 と `Allow` を返します
123
+ - `QUERY` メソッド([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html))は `app.on("QUERY", path, handler)` で登録します。ボディがなければ 400、Content-Type がなければ 415 にします
124
+ - CONNECT / TRACE / TRACK は扱いません(501)
125
+ - 同じメソッド + 同じパス(パラメーター名だけ違うものも含む)を2回登録するとエラーになります
126
+
127
+ ### ハンドラーの戻り値
128
+
129
+ | 返すもの | レスポンス |
130
+ | --- | --- |
131
+ | 文字列 | `text/plain; charset=utf-8` |
132
+ | オブジェクト・配列 | JSON |
133
+ | `Blob` / `ArrayBuffer` / `Uint8Array` など / `ReadableStream` / `FormData` / `URLSearchParams` | そのままボディ |
134
+ | `Response` | そのまま |
135
+
136
+ 関数を返す(呼び忘れ)のは型エラーで、実行時も 500 になります。
137
+
138
+ ### グループ化
139
+
140
+ ```ts
141
+ import { createRouter } from "@senrijs/senri";
142
+
143
+ const posts = createRouter("/users/:id/posts");
144
+ posts.get("/", ({ id }) => `user ${id} の投稿`); // id に型が付く
145
+ posts.get("/:postId", ({ id, postId }) => `${id} / ${postId}`);
146
+ app.route(posts);
147
+
148
+ app.route("/admin", adminApp); // 別の createApp() をつなぐこともできる
149
+ ```
150
+
151
+ ### パスのルール
152
+
153
+ - 末尾スラッシュと連続スラッシュは無視します。`/users`、`/users/`、`//users` はどれも同じです
154
+ - パスはセグメントごとにパーセントデコードされます。`/こんにちは` のようにそのまま書いたルートにマッチします
155
+ - デコードできないパスと、`%2F`(エンコードされた `/`)を含むパスは 400 です
156
+ - 正規化・デコード済みのパスは `c.path` で読めます。パスで判定するミドルウェアは、`req.url` ではなく `c.path` か `app.use("/admin", mw)` を使ってください
157
+
158
+ ## リクエストを読む
159
+
160
+ ```ts
161
+ app.post("/search", async (_, c) => {
162
+ c.query("page"); // "2"(なければ undefined。c.query() で全部)
163
+ c.queries("tag"); // ["a", "b"]
164
+ await c.body(); // Content-Type に合わせて読む(JSON・フォーム・テキスト・それ以外は ArrayBuffer)
165
+ c.cookie("session"); // Cookie
166
+ c.ip; // 接続元の IP アドレス(分からなければ null)
167
+ return "ok";
168
+ });
169
+ ```
170
+
171
+ - `c.body()` は何度呼んでも同じ結果です(ミドルウェアとハンドラーの両方から読めます)
172
+ - `c.ip` はランタイムが渡す接続元を使います。`X-Forwarded-For` などのヘッダーは偽装できるので読みません。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読んでください
173
+ - `c.setCookie(name, value, options)` の既定は `Path=/`・`HttpOnly`・`SameSite=Lax` です
174
+
175
+ ## バリデーション
176
+
177
+ ルートにスキーマを渡すと、ハンドラーを呼ぶ前に確かめます。[Standard Schema](https://standardschema.dev) に対応したライブラリ(Zod・Valibot・ArkType など)のスキーマが、そのまま使えます。
178
+
179
+ ```ts
180
+ import { z } from "zod";
181
+
182
+ app.post(
183
+ "/users/:id",
184
+ {
185
+ params: z.object({ id: z.coerce.number() }),
186
+ body: z.object({ name: z.string().min(1) }),
187
+ },
188
+ ({ id }, c) => {
189
+ const { name } = c.valid("body"); // { name: string }
190
+ return { id, name }; // id: number
191
+ },
192
+ );
193
+ ```
194
+
195
+ - `params`・`query`・`body` を渡せます。`params` が通れば、ハンドラーの第1引数が変換後の値になります
196
+ - 通らなければ 400 です。本番の本文は `400 Bad Request` だけで、開発モードでは、どこが違うかを一覧にします
197
+ - 自分の形で返したいときは、`app.onError` で `ValidationError` を受けます(`err.target` と `err.issues`)
198
+
199
+ ## ミドルウェア
200
+
201
+ ```ts
202
+ app.use(async (c, next) => {
203
+ const res = await next(); // 下流(後ろのミドルウェアとハンドラー)を動かす
204
+ c.header("x-powered-by", "senri");
205
+ return res;
206
+ });
207
+
208
+ // /admin とその下だけで動く
209
+ app.use("/admin", (c, next) => {
210
+ if (c.req.headers.get("authorization") !== "secret") {
211
+ c.status(401);
212
+ return "ログインしてね"; // 値を返したら、そこで終わり
213
+ }
214
+ return next();
215
+ });
216
+ ```
217
+
218
+ - ルートはミドルウェアより先にマッチするので、`next()` の前から `c.route`(`"/users/:id"` のようなパターン)と `c.params` が読めます
219
+ - `c.header()` で設定したヘッダーは、最後に1回だけレスポンスに足されます。`Response` を直接返したときも、エラーのときも効きます
220
+ - ミドルウェアからハンドラーへ値を渡すには `c.set` / `c.get` を使います。型は `createApp<{ user: User }>()` で決めます
221
+
222
+ ### 標準のミドルウェア
223
+
224
+ ```ts
225
+ import { bodyLimit, cors, health, logger, secureHeaders } from "@senrijs/senri";
226
+ import { serveStatic } from "@senrijs/senri/static";
227
+
228
+ app.use(health({ ready: () => db.isConnected() })); // /healthz と /readyz
229
+ app.use(logger()); // 色付きで1行ずつ。{ format: "json" } で pino 形式
230
+ app.use(secureHeaders()); // X-Content-Type-Options など
231
+ app.use(cors({ origin: ["https://example.com"] }));
232
+ app.use(bodyLimit({ maxSize: 1024 * 1024 })); // 超えたら 413
233
+ app.use(serveStatic({ root: "./public" })); // Bun・Deno・Node.js で静的ファイルを配る
234
+ ```
235
+
236
+ - `health()`: `/healthz` は応答できれば 200。`/readyz` は `ready()` が true なら 200、false・例外・止まり始めたあとは 503
237
+ - `logger()`: 色は既定でオンです。ファイルや CI に流すときは `color: false` にしてください
238
+ - `secureHeaders()`: Content-Security-Policy と Cross-Origin-Embedder-Policy は既定でオフです(アプリによって壊れやすいので)
239
+ - `serveStatic()`: ドットファイルは既定で配りません。root の外(シンボリックリンクを含む)には出ません
240
+
241
+ ## エラー
242
+
243
+ ```ts
244
+ import { HttpError } from "@senrijs/senri";
245
+
246
+ app.get("/secret", () => {
247
+ throw new HttpError(403, "見せられません"); // message はそのままクライアントに返る
248
+ });
249
+
250
+ app.onError((err, c) => ({ error: "something went wrong" }));
251
+ app.notFound((c) => `ありません: ${c.path}`);
252
+ ```
253
+
254
+ ## 開発モード
255
+
256
+ ```ts
257
+ const app = createApp({ dev: true });
258
+ ```
259
+
260
+ 開発モードのときだけ、次のものが動きます。本番(既定)では、固定の文言(`500 Internal Server Error` など)だけを返し、計測も記録もしません。
261
+
262
+ - 500 の本文にエラーメッセージ、404 に「もしかして: GET /hello ?」、405 にどのメソッドならあるか、バリデーションの 400 に違うところの一覧
263
+ - **エラーページと 404 ページ**: ブラウザから開いたとき(`Accept` に `text/html` があるとき)だけ。エラーが起きた行の前後のコード、ヒント、スタックトレース、ミドルウェアを通った順番と時間。エラーの場所は VS Code・Cursor・WebStorm・Zed で開けます
264
+ - **ダッシュボード(`/_senri`)**: 直近 200 件のリクエストをリアルタイムで。玉ねぎタイムライン(ミドルウェアごとの自分の時間と下流込みの時間)、ルートとミドルウェアの一覧
265
+ - **`Server-Timing` ヘッダー**: 層ごとの時間が、ブラウザの開発者ツールの「タイミング」に出ます
266
+
267
+ 開発モードを決めるのに環境変数(`NODE_ENV` など)は読みません。本番では `dev: true` にしないでください。
268
+
269
+ ダッシュボードの記録とセキュリティについて:
270
+
271
+ - 記録はメモリ上だけで、再起動すると消えます。`/_senri` 自体へのリクエストと、リクエストやレスポンスのボディは記録しません
272
+ - `authorization`・`cookie`・`set-cookie` や、名前に `token`・`secret`・`session`・`api-key` などを含むヘッダーとクエリは `***` に伏せます
273
+ - **同じ PC(localhost)からしか開けません。** 接続元がループバックで、かつ `Host` も `localhost` などのときだけです。それ以外は `/_senri` がないものとして扱います(ふつうの 404)
274
+ - 接続元は `app.fetch` の2つ目の引数から取ります。`fetch: (req) => app.fetch(req)` のように包むと渡らないので、`(req, info) => app.fetch(req, info)` としてください
275
+ - Docker などのコンテナの中で動かすと、接続元が localhost に見えないので、ダッシュボードは開けません
276
+
277
+ ## 性能
278
+
279
+ Hono 4.13.13・Elysia 1.4.30 と、同じルート(12 個)・同じリクエストで比べました。どれも本番モード、ミドルウェアなし、ハンドラーはテキストを返すだけです。手順とコードは [bench/](./bench/) にあり、だれでも同じものを動かせます。
280
+
281
+ **計測した環境**: Intel N100(4 コア)、Windows 11、Bun 1.4.3。ほかのアプリも動いているふだん使いの PC で、数字は回によって 1〜2 割ほどぶれます。
282
+
283
+ ### プロセスの中で `app.fetch` を呼ぶ(1 リクエストあたりの時間。小さいほど速い)
284
+
285
+ ネットワークを通さず、`Request` を渡してから `Response` の本文を読み終えるまで。`bun run fetch`(mitata 1.0.34)。
286
+
287
+ | リクエスト | Senri | Hono | Elysia |
288
+ | --- | ---: | ---: | ---: |
289
+ | 静的(`/user`) | 1.63 µs | 0.90 µs | 0.58 µs |
290
+ | 静的・長い | 1.57 µs | 1.41 µs | 0.81 µs |
291
+ | パラメーター1つ | 3.00 µs | 1.60 µs | 0.90 µs |
292
+ | パラメーター3つ | 3.12 µs | 1.95 µs | 1.24 µs |
293
+ | 404 | 4.97 µs | 3.56 µs | 1.01 µs |
294
+
295
+ この計測では、Senri は3つの中でいちばん遅く、Hono の 1.1〜1.9 倍の時間がかかっています。理由の一部は分かっています。
296
+
297
+ - Senri は、どのランタイムでも同じになるよう、テキストの `Content-Type` を自分で付けます。Hono と Elysia は `new Response(text)` でランタイムに任せます
298
+ - Elysia は登録したルートからコードを生成して最適化します。Senri は Cloudflare Workers でも動くよう、`new Function` / `eval` を使いません
299
+
300
+ ### HTTP 越し(1 秒あたりのリクエスト数。大きいほど速い)
301
+
302
+ 3 つとも同じ `Bun.serve({ fetch })` で動かし、[oha](https://github.com/hatoo/oha) 1.16.0 で負荷をかけました(`oha -z 10s -c 64`、交互に 3 回ずつ測った中央値)。`bun run http`。
303
+
304
+ | リクエスト | Senri | Hono | Elysia |
305
+ | --- | ---: | ---: | ---: |
306
+ | 静的(`/user`) | 21,703 | 17,167 | 14,447 |
307
+ | パラメーター1つ | 16,390 | 17,461 | 21,516 |
308
+ | パラメーター3つ | 19,426 | 23,692 | 12,186 |
309
+ | 404 | 22,349 | 23,030 | 24,761 |
310
+
311
+ 負荷をかける側も同じ PC で動いているので、サーバーそのものの処理が大半を占めます。この条件では 3 つとも同じくらいの範囲に入り、どれが速いとは言い切れません。順位は回によって入れ替わります。
312
+
313
+ ## 例
314
+
315
+ `examples/` に、Bun・Deno・Node.js・Cloudflare Workers(workerd)で同じアプリを動かす例があります。
316
+
317
+ ```bash
318
+ bun run dev # Bun(保存すると再起動)
319
+ cd examples/deno && deno task start # Deno
320
+ node examples/node/index.ts # Node.js
321
+ ```
322
+
323
+ ## ロードマップ・貢献
324
+
325
+ - これからの予定は [ROADMAP.ja.md](./ROADMAP.ja.md) にあります
326
+ - 開発のしかたは [CONTRIBUTING.md](./CONTRIBUTING.md)、変更点は [CHANGELOG.md](./CHANGELOG.md) にあります
327
+
328
+ ## ライセンス
329
+
330
+ [MIT](./LICENSE) © 2026 ibuki-hum4
331
+
332
+ Senri のロゴ(`.github/assets/` の画像)は、MIT ライセンスの対象外です。
@@ -0,0 +1,14 @@
1
+ import { type Server } from "node:http";
2
+ type App = {
3
+ fetch: (req: Request, info?: {
4
+ remoteAddr: {
5
+ hostname: string;
6
+ port: number;
7
+ transport: "tcp";
8
+ };
9
+ }) => Response | Promise<Response>;
10
+ };
11
+ export declare function serve(app: App, options: {
12
+ port: number;
13
+ }): Server;
14
+ export {};
@@ -0,0 +1,152 @@
1
+ import { createServer } from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ function toUrl(req) {
5
+ const protocol = req.socket.encrypted ? "https" : "http";
6
+ // HTTP/1.0 などで Host がないこともある
7
+ const host = req.headers.host ?? "localhost";
8
+ // Host は下で URL の文字列につなぐので、区切り文字が入っているとパスやホストが化ける
9
+ // (例: "x/admin#" → http://x/admin#/path となり、パスが /admin になる)
10
+ if (/[\s/?#@\\]/.test(host)) {
11
+ throw new TypeError(`不正な Host ヘッダーです: ${host}`);
12
+ }
13
+ const target = req.url ?? "/";
14
+ // absolute-form("GET http://host/path HTTP/1.1")ならそのまま使う
15
+ if (/^https?:\/\//i.test(target)) {
16
+ return new URL(target);
17
+ }
18
+ // "/" で始まらないもの("@evil.com/x" や "*")は、つなぐとホスト名などに化けるので受け付けない
19
+ // (Node の HTTP パーサーがたいてい先に 400 にするが、念のため)
20
+ if (!target.startsWith("/")) {
21
+ throw new TypeError(`不正なリクエストターゲットです: ${target}`);
22
+ }
23
+ // new URL(target, base) にすると "//admin" が「プロトコル相対 URL」として
24
+ // ホスト名 admin と解釈されてしまうので、文字列でつないでから URL にする
25
+ // Host が "a b" のように不正だとここで TypeError になる
26
+ return new URL(`${protocol}://${host}${target}`);
27
+ }
28
+ function toRequest(req, signal) {
29
+ const headers = new Headers();
30
+ for (const [key, value] of Object.entries(req.headers)) {
31
+ if (value === undefined)
32
+ continue;
33
+ if (Array.isArray(value)) {
34
+ for (const v of value)
35
+ headers.append(key, v);
36
+ }
37
+ else {
38
+ headers.set(key, value);
39
+ }
40
+ }
41
+ // GET / HEAD はボディを持てない(渡すと Request が TypeError を投げる)
42
+ // ボディがない(Content-Length も Transfer-Encoding もない)ときは、空のストリームではなく null にする
43
+ // (Bun / Deno と同じ。QUERY の「ボディがない」を見分けるのに使う)
44
+ const hasBody = req.method !== "GET" &&
45
+ req.method !== "HEAD" &&
46
+ (req.headers["content-length"] !== undefined || req.headers["transfer-encoding"] !== undefined);
47
+ return new Request(toUrl(req), {
48
+ method: req.method,
49
+ headers,
50
+ body: hasBody ? Readable.toWeb(req) : undefined,
51
+ // ストリームをボディにするときに必要(読みながら送る「半二重」の指定)
52
+ duplex: "half",
53
+ signal,
54
+ });
55
+ }
56
+ async function writeResponse(res, response) {
57
+ res.statusCode = response.status;
58
+ response.headers.forEach((value, key) => {
59
+ // set-cookie は1つずつ来るので、setHeader で上書きしないよう下でまとめて入れる
60
+ if (key !== "set-cookie")
61
+ res.setHeader(key, value);
62
+ });
63
+ const cookies = response.headers.getSetCookie();
64
+ if (cookies.length > 0)
65
+ res.setHeader("set-cookie", cookies);
66
+ if (!response.body) {
67
+ res.end();
68
+ return;
69
+ }
70
+ // 全部ためてから送るのではなく、届いたチャンクから順に流す
71
+ // pipeline は書き込みが詰まったら読むのを待ってくれる(バックプレッシャー)
72
+ await pipeline(Readable.fromWeb(response.body), res);
73
+ }
74
+ // 扱わないメソッド。CONNECT はプロキシ用、TRACE / TRACK はリクエストをそのまま返すので XST 攻撃に使われる
75
+ const NOT_IMPLEMENTED = new Set(["CONNECT", "TRACE", "TRACK"]);
76
+ function notImplemented(socket) {
77
+ const body = "501 Not Implemented";
78
+ socket.end([
79
+ "HTTP/1.1 501 Not Implemented",
80
+ "Content-Type: text/plain; charset=utf-8",
81
+ `Content-Length: ${body.length}`,
82
+ "Connection: close",
83
+ "",
84
+ body,
85
+ ].join("\r\n"));
86
+ }
87
+ export function serve(app, options) {
88
+ const server = createServer(async (req, res) => {
89
+ if (NOT_IMPLEMENTED.has(req.method ?? "")) {
90
+ res.writeHead(501, { "content-type": "text/plain; charset=utf-8", connection: "close" });
91
+ res.end("501 Not Implemented");
92
+ return;
93
+ }
94
+ // レスポンスを送り終える前に接続が切れたら、アプリに c.req.signal で知らせる
95
+ const controller = new AbortController();
96
+ res.once("close", () => {
97
+ if (!res.writableFinished)
98
+ controller.abort();
99
+ });
100
+ let request;
101
+ try {
102
+ request = toRequest(req, controller.signal);
103
+ }
104
+ catch {
105
+ res.statusCode = 400;
106
+ res.end("400 Bad Request");
107
+ return;
108
+ }
109
+ try {
110
+ const info = {
111
+ remoteAddr: { hostname: req.socket.remoteAddress ?? "", port: req.socket.remotePort ?? 0, transport: "tcp" },
112
+ };
113
+ await writeResponse(res, await app.fetch(request, info));
114
+ }
115
+ catch (err) {
116
+ // クライアントが途中で切っただけならエラーではない
117
+ if (controller.signal.aborted)
118
+ return;
119
+ // ここで投げ直すと unhandled rejection でプロセスごと落ちるので、必ず握る
120
+ console.error(err);
121
+ if (res.headersSent) {
122
+ res.destroy();
123
+ }
124
+ else {
125
+ res.statusCode = 500;
126
+ res.end("500 Internal Server Error");
127
+ }
128
+ }
129
+ });
130
+ // CONNECT は request ではなく connect イベントで来る(聞いていないと、何も返さずに切ってしまう)
131
+ server.on("connect", (_req, socket) => notImplemented(socket));
132
+ // Node の HTTP パーサーが知らないメソッドは、アプリに届く前にエラーになる。既定の 400 ではなく 501 にする
133
+ server.on("clientError", (err, socket) => {
134
+ if (!socket.writable)
135
+ return socket.destroy();
136
+ // ほかは Node の既定と同じ status にする(リスナーを付けると、既定の処理は動かなくなる)
137
+ const status = err.code === "HPE_INVALID_METHOD"
138
+ ? "501 Not Implemented"
139
+ : err.code === "HPE_HEADER_OVERFLOW"
140
+ ? "431 Request Header Fields Too Large"
141
+ : err.code === "HPE_CHUNK_EXTENSIONS_OVERFLOW"
142
+ ? "413 Payload Too Large"
143
+ : err.code === "ERR_HTTP_REQUEST_TIMEOUT"
144
+ ? "408 Request Timeout"
145
+ : "400 Bad Request";
146
+ if (status.startsWith("501"))
147
+ return notImplemented(socket);
148
+ socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
149
+ });
150
+ server.listen(options.port);
151
+ return server;
152
+ }
@@ -0,0 +1,28 @@
1
+ type App = {
2
+ fetch: (req: Request) => Response | Promise<Response>;
3
+ routeList(): {
4
+ method: string;
5
+ path: string;
6
+ }[];
7
+ };
8
+ type ServeOptions = {
9
+ port?: number;
10
+ hostname?: string;
11
+ banner?: boolean;
12
+ gracefulShutdown?: boolean;
13
+ shutdownTimeout?: number;
14
+ onShutdown?: () => unknown;
15
+ };
16
+ export type Server = {
17
+ port: number;
18
+ url: string;
19
+ stop(): Promise<void>;
20
+ };
21
+ export declare const DEFAULT_PORT = 3927;
22
+ export type PortSource = "option" | "env" | "default";
23
+ export declare function resolvePort(explicit: number | undefined, env?: (name: string) => string | undefined): {
24
+ port: number;
25
+ source: PortSource;
26
+ };
27
+ export declare function serve(app: App, options?: ServeOptions): Promise<Server>;
28
+ export {};