@senrijs/senri 0.1.0 → 0.2.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.
- package/README.md +62 -16
- package/dist/src/app.d.ts +41 -4
- package/dist/src/app.js +67 -117
- package/dist/src/compose.d.ts +5 -5
- package/dist/src/compose.js +15 -30
- package/dist/src/context.d.ts +4 -6
- package/dist/src/context.js +15 -34
- package/dist/src/cookie.d.ts +5 -0
- package/dist/src/cookie.js +15 -3
- package/dist/src/dev/explain.d.ts +2 -0
- package/dist/src/dev/explain.js +37 -0
- package/dist/src/dev/hints.js +6 -3
- package/dist/src/dev/index.d.ts +3 -6
- package/dist/src/dev/index.js +184 -10
- package/dist/src/dev/not-found.d.ts +3 -0
- package/dist/src/dev/not-found.js +15 -0
- package/dist/src/dev/registry.d.ts +3 -0
- package/dist/src/dev/registry.js +7 -0
- package/dist/src/dev/validation.d.ts +3 -0
- package/dist/src/dev/validation.js +47 -0
- package/dist/src/errors.d.ts +7 -5
- package/dist/src/errors.js +16 -55
- package/dist/src/group.d.ts +0 -1
- package/dist/src/group.js +8 -16
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +3 -1
- package/dist/src/ip.d.ts +2 -0
- package/dist/src/ip.js +12 -0
- package/dist/src/router.js +5 -5
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/validate.d.ts +1 -2
- package/dist/src/validate.js +12 -44
- package/package.json +19 -3
- package/dist/src/dev/runtime.d.ts +0 -3
- package/dist/src/dev/runtime.js +0 -45
package/README.md
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
9
|
Web 標準の <code>Request</code> / <code>Response</code> だけで書かれた、小さな Web フレームワーク。<br>
|
|
10
|
-
Bun・Deno・Node.js・Cloudflare Workers で、同じコードが動きます。
|
|
10
|
+
Bun・Deno・Node.js・Cloudflare Workers・ブラウザ(Service Worker)で、同じコードが動きます。
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
import { createApp } from "@senrijs/senri";
|
|
15
|
+
import { devtools } from "@senrijs/senri/dev";
|
|
15
16
|
import { serve } from "@senrijs/senri/serve";
|
|
16
17
|
|
|
17
|
-
const app = createApp();
|
|
18
|
+
const app = createApp({ dev: devtools() }); // 開発中の親切なエラーページなど。NODE_ENV=production では何もしない
|
|
18
19
|
|
|
19
20
|
app.get("/hello", () => "こんにちは!"); // 文字列 → text/plain
|
|
20
21
|
app.get("/users/:id", ({ id }) => ({ id })); // オブジェクト → JSON(id の型はパスから推論)
|
|
@@ -22,10 +23,12 @@ app.get("/users/:id", ({ id }) => ({ id })); // オブジェクト → JSON(i
|
|
|
22
23
|
serve(app); // Bun でも Deno でも Node.js でも
|
|
23
24
|
```
|
|
24
25
|
|
|
26
|
+
本番のビルドでは `dev: devtools()` を外すと、開発用のコードがバンドルに入らず、本体だけ(minify して約 14KB)になります([開発モード](#開発モード))。
|
|
27
|
+
|
|
25
28
|
- **返した値がレスポンスになる。** 文字列はテキスト、オブジェクトは JSON。`Response` もそのまま返せます
|
|
26
29
|
- **型はパスから。** `/users/:id` と書けば、ハンドラーの `id` に型が付きます。[Standard Schema](https://standardschema.dev)(Zod・Valibot・ArkType など)で、パラメーター・クエリ・ボディも確かめられます
|
|
27
30
|
- **開発モードが親切。** エラーページ(該当コード・ヒント・エディタで開く)、404 の「もしかして」、リクエストを玉ねぎのように見られるダッシュボード(`/_senri`)、層ごとの `Server-Timing`
|
|
28
|
-
- **どこでも動く。** Web 標準の API だけで書いていて、`eval` も `new Function`
|
|
31
|
+
- **どこでも動く。** Web 標準の API だけで書いていて、`eval` も `new Function` も使いません。ブラウザの Service Worker でも動きます
|
|
29
32
|
|
|
30
33
|
## インストール
|
|
31
34
|
|
|
@@ -86,10 +89,31 @@ export default app;
|
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
- 変数の型も決めるときは `createApp<{ Bindings: Env; Variables: { user: User } }>()` と書きます。変数だけなら、今までどおり `createApp<{ user: User }>()` です
|
|
89
|
-
- `c
|
|
92
|
+
- `getIp(c)`(`@senrijs/senri/ip`)は、Cloudflare が付ける `CF-Connecting-IP` を読みます(Workers で動いているときだけ)
|
|
90
93
|
- 開発モードのダッシュボードは作りません(`/_senri` はふつうの 404)。エラーページは出ますが、該当コードは出ません
|
|
94
|
+
- wrangler は1つのファイルにまとめるので、`dev: devtools()` を残すと、開発用のコード(約 140KB)もバンドルに入ります。本番のビルドでは外してください
|
|
95
|
+
- Workers では `NODE_ENV` が読めないので、`devtools()` は `localhost` へのリクエスト(`wrangler dev`)のときだけ開発用の出力をします([開発モード](#開発モード))
|
|
91
96
|
- 静的ファイルは `@senrijs/senri/static` ではなく、Workers の [Static Assets](https://developers.cloudflare.com/workers/static-assets/) で配ってください
|
|
92
97
|
|
|
98
|
+
### ブラウザ(Service Worker)
|
|
99
|
+
|
|
100
|
+
Service Worker の `fetch` イベントに、そのまま答えられます。サーバーなしで、ページからの `fetch` に Senri が返事をします。
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// sw.ts(bun build や Vite でまとめて、navigator.serviceWorker.register("/sw.js") で登録する)
|
|
104
|
+
import { createApp } from "@senrijs/senri";
|
|
105
|
+
|
|
106
|
+
const app = createApp();
|
|
107
|
+
app.get("/api/todos/:id", ({ id }) => ({ id, done: false }));
|
|
108
|
+
|
|
109
|
+
self.addEventListener("fetch", (e) => {
|
|
110
|
+
if (new URL(e.request.url).pathname.startsWith("/api/")) e.respondWith(app.fetch(e.request));
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- ページの中で `app.fetch(new Request(...))` を直接呼ぶこともできます
|
|
115
|
+
- CI で、Chromium のページと Service Worker の両方で動くことを確かめています(`scripts/browser.ts`)
|
|
116
|
+
|
|
93
117
|
### 止め方(graceful shutdown)
|
|
94
118
|
|
|
95
119
|
`serve()` は SIGTERM / SIGINT を受けると、次の順に止まります。
|
|
@@ -158,19 +182,24 @@ app.route("/admin", adminApp); // 別の createApp()
|
|
|
158
182
|
## リクエストを読む
|
|
159
183
|
|
|
160
184
|
```ts
|
|
185
|
+
import { getCookie, setCookie } from "@senrijs/senri/cookie";
|
|
186
|
+
import { getIp } from "@senrijs/senri/ip";
|
|
187
|
+
|
|
161
188
|
app.post("/search", async (_, c) => {
|
|
162
|
-
c.query("page");
|
|
163
|
-
c.queries("tag");
|
|
164
|
-
await c.body();
|
|
165
|
-
c
|
|
166
|
-
c
|
|
189
|
+
c.query("page"); // "2"(なければ undefined。c.query() で全部)
|
|
190
|
+
c.queries("tag"); // ["a", "b"]
|
|
191
|
+
await c.body(); // Content-Type に合わせて読む(JSON・フォーム・テキスト・それ以外は ArrayBuffer)
|
|
192
|
+
getCookie(c, "session"); // Cookie(getCookie(c) で全部)
|
|
193
|
+
setCookie(c, "seen", "1"); // deleteCookie(c, name) で消す
|
|
194
|
+
getIp(c); // 接続元の IP アドレス(分からなければ null)
|
|
167
195
|
return "ok";
|
|
168
196
|
});
|
|
169
197
|
```
|
|
170
198
|
|
|
171
199
|
- `c.body()` は何度呼んでも同じ結果です(ミドルウェアとハンドラーの両方から読めます)
|
|
172
|
-
-
|
|
173
|
-
- `c
|
|
200
|
+
- Cookie と IP は、使わないアプリのバンドルに入らないよう、別の入口(`@senrijs/senri/cookie`・`@senrijs/senri/ip`)にしています
|
|
201
|
+
- `getIp(c)` はランタイムが渡す接続元を使います。`X-Forwarded-For` などのヘッダーは偽装できるので読みません。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読んでください
|
|
202
|
+
- `setCookie(c, name, value, options)` の既定は `Path=/`・`HttpOnly`・`SameSite=Lax` です
|
|
174
203
|
|
|
175
204
|
## バリデーション
|
|
176
205
|
|
|
@@ -193,7 +222,7 @@ app.post(
|
|
|
193
222
|
```
|
|
194
223
|
|
|
195
224
|
- `params`・`query`・`body` を渡せます。`params` が通れば、ハンドラーの第1引数が変換後の値になります
|
|
196
|
-
- 通らなければ 400 です。本番の本文は `400 Bad Request`
|
|
225
|
+
- 通らなければ 400 です。本番の本文は `400 Bad Request` だけで、開発モード(`devtools()`)では、どこが違うかを一覧にします。スキーマの書き間違いも、開発モードなら登録した時点で知らせます
|
|
197
226
|
- 自分の形で返したいときは、`app.onError` で `ValidationError` を受けます(`err.target` と `err.issues`)
|
|
198
227
|
|
|
199
228
|
## ミドルウェア
|
|
@@ -254,17 +283,34 @@ app.notFound((c) => `ありません: ${c.path}`);
|
|
|
254
283
|
## 開発モード
|
|
255
284
|
|
|
256
285
|
```ts
|
|
257
|
-
|
|
286
|
+
import { createApp } from "@senrijs/senri";
|
|
287
|
+
import { devtools } from "@senrijs/senri/dev";
|
|
288
|
+
|
|
289
|
+
const app = createApp({ dev: devtools() });
|
|
258
290
|
```
|
|
259
291
|
|
|
260
|
-
|
|
292
|
+
開発用の機能は、すべて `@senrijs/senri/dev` にあります。本体(`@senrijs/senri`)は開発用のコードを一切 import しないので、`devtools()` を渡さなければ、どのバンドラーでも開発用のコードは入りません。`devtools()` を渡したときだけ、次のものが動きます。渡さなければ、固定の文言(`500 Internal Server Error` など)だけを返し、計測も記録もしません。
|
|
261
293
|
|
|
262
|
-
- 500 の本文にエラーメッセージ、404 に「もしかして: GET /hello ?」、405 にどのメソッドならあるか、バリデーションの 400
|
|
294
|
+
- 500 の本文にエラーメッセージ、404 に「もしかして: GET /hello ?」、405 にどのメソッドならあるか、バリデーションの 400 に違うところの一覧、登録時のエラーに日本語の説明
|
|
263
295
|
- **エラーページと 404 ページ**: ブラウザから開いたとき(`Accept` に `text/html` があるとき)だけ。エラーが起きた行の前後のコード、ヒント、スタックトレース、ミドルウェアを通った順番と時間。エラーの場所は VS Code・Cursor・WebStorm・Zed で開けます
|
|
264
296
|
- **ダッシュボード(`/_senri`)**: 直近 200 件のリクエストをリアルタイムで。玉ねぎタイムライン(ミドルウェアごとの自分の時間と下流込みの時間)、ルートとミドルウェアの一覧
|
|
265
297
|
- **`Server-Timing` ヘッダー**: 層ごとの時間が、ブラウザの開発者ツールの「タイミング」に出ます
|
|
266
298
|
|
|
267
|
-
|
|
299
|
+
本番での扱いは、2通りあります。**本番のビルドから外すのが確実です。**
|
|
300
|
+
|
|
301
|
+
- **本番のビルドから `devtools()` を外す(`dev` を渡さない)**: 開発用のコードがバンドルに入らず、サイズが最小になります。依存は増えません(同じパッケージの別の入口なので)
|
|
302
|
+
- **`dev: devtools()` を残して `NODE_ENV=production` にする**: `devtools()` は何もしないので、本番に開発ページは出ません。ただし、1つのファイルにまとめるバンドラー(Workers の wrangler など)では、コードが入ったままなのでサイズは減りません
|
|
303
|
+
|
|
304
|
+
**Cloudflare Workers とブラウザ(Service Worker)では `NODE_ENV` が読めない**ので、`devtools()` は、リクエストの URL のホスト名が `localhost`(`127.0.0.1`・`[::1]`・`*.localhost` を含む)のときだけ開発用の出力をします。それ以外のリクエストには、本番と同じ固定の文言だけを返し、エラーページ・ルートの一覧・`Server-Timing` は出しません。
|
|
305
|
+
|
|
306
|
+
- 見るのは URL のホスト名だけです。`Host` や `X-Forwarded-Host` などのヘッダーは、だれでも付けられるので見ません
|
|
307
|
+
- Workers では、本番の Worker に `localhost` のリクエストは届かない(Cloudflare は登録されたドメインのリクエストしか渡さない)ので、`wrangler dev`(`localhost:8787`)のときだけ有効になります
|
|
308
|
+
- ブラウザでは、開発中のサイト(`localhost`)のときだけ有効になります
|
|
309
|
+
- それでも、本番のビルドからは外すのが確実です。消し忘れても漏れないようにするための、念のための仕組みです
|
|
310
|
+
|
|
311
|
+
Deno では、`NODE_ENV` を読む許可(`--allow-env=NODE_ENV`)がないと本番かどうか分からないので、`devtools()` は無効になり、その旨を1行出します。
|
|
312
|
+
|
|
313
|
+
0.1 の `createApp({ dev: true })` は、0.2 からエラーになります([CHANGELOG](./CHANGELOG.md) に移行方法があります)。
|
|
268
314
|
|
|
269
315
|
ダッシュボードの記録とセキュリティについて:
|
|
270
316
|
|
package/dist/src/app.d.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
import { type ExecutionContext, type Handler, type Middleware } from "./context.ts";
|
|
2
|
-
import { type ExtractParams, type RouteTable, type Simplify } from "./router.ts";
|
|
3
|
-
import
|
|
1
|
+
import { Context, type ExecutionContext, type Handler, type Middleware } from "./context.ts";
|
|
2
|
+
import { type ExtractParams, type RouteTable, type Route, type Simplify } from "./router.ts";
|
|
3
|
+
import { type Layer } from "./compose.ts";
|
|
4
|
+
import { type RouteSchemas, type Validated, type ValidatedParams } from "./validate.ts";
|
|
4
5
|
import { type Router } from "./group.ts";
|
|
5
6
|
import { type ErrorHandler, type NotFoundHandler } from "./errors.ts";
|
|
7
|
+
export type Resolved = {
|
|
8
|
+
kind: "route";
|
|
9
|
+
route: Route;
|
|
10
|
+
params: Record<string, string>;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "options";
|
|
13
|
+
allow: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "methodNotAllowed";
|
|
16
|
+
allow: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "notFound";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "notImplemented";
|
|
21
|
+
} | {
|
|
22
|
+
kind: "badRequest";
|
|
23
|
+
error: unknown;
|
|
24
|
+
};
|
|
6
25
|
export type RouteMethod<V extends object> = {
|
|
7
26
|
<Path extends string>(path: Path, handler: Handler<Simplify<ExtractParams<Path>>, V>): void;
|
|
8
27
|
<Path extends string, S extends RouteSchemas>(path: Path, schemas: S, handler: Handler<ValidatedParams<S, Simplify<ExtractParams<Path>>>, V, Validated<S>>): void;
|
|
@@ -44,6 +63,24 @@ export type App<V extends object = Record<string, unknown>> = {
|
|
|
44
63
|
fetch(req: Request, info?: unknown, ctx?: ExecutionContext): Promise<Response>;
|
|
45
64
|
};
|
|
46
65
|
export type AppOptions = {
|
|
47
|
-
dev?:
|
|
66
|
+
dev?: DevHooks | undefined;
|
|
67
|
+
};
|
|
68
|
+
export type DevHooks = {
|
|
69
|
+
install(core: AppCore): DevInstalled;
|
|
70
|
+
};
|
|
71
|
+
export type AppCore = {
|
|
72
|
+
app: App<any>;
|
|
73
|
+
resolve(c: Context): Resolved;
|
|
74
|
+
layers(c: Context): Layer[];
|
|
75
|
+
inner(c: Context, resolved: Resolved): Response | Promise<Response>;
|
|
76
|
+
fail(err: unknown, c: Context): Promise<Response>;
|
|
77
|
+
routes(): readonly Route[];
|
|
78
|
+
};
|
|
79
|
+
export type DevInstalled = {
|
|
80
|
+
fetch(req: Request, info?: unknown, ctx?: ExecutionContext): Promise<Response>;
|
|
81
|
+
onError: ErrorHandler;
|
|
82
|
+
notFound: NotFoundHandler;
|
|
83
|
+
checkRoute(method: string, path: string, schemas: RouteSchemas | undefined): void;
|
|
84
|
+
explain(err: unknown): unknown;
|
|
48
85
|
};
|
|
49
86
|
export declare function createApp<V extends object = Record<string, unknown>>(options?: AppOptions): App<V>;
|
package/dist/src/app.js
CHANGED
|
@@ -1,76 +1,65 @@
|
|
|
1
1
|
import { Context } from "./context.js";
|
|
2
|
-
import {
|
|
2
|
+
import { allowedMethods, checkParamNames, normalizeMethod, routeShape, splitPattern, startsWithSegments, } from "./router.js";
|
|
3
3
|
import { compose } from "./compose.js";
|
|
4
4
|
import { createMatcher } from "./matcher.js";
|
|
5
|
-
import {
|
|
6
|
-
import { groupEntries, joinPath
|
|
7
|
-
import { defaultNotFound, defaultOnError, checkQueryRequest, handleError, HttpError, markConversionError, methodNotAllowed, } from "./errors.js";
|
|
8
|
-
// パスが /_senri か /_senri/... なら、"_senri" より後ろのセグメント。そうでなければ null
|
|
9
|
-
function underDashboard(c) {
|
|
10
|
-
try {
|
|
11
|
-
return c.segments[0] === "_senri" ? c.segments.slice(1) : null;
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
// 計測で、いちばん内側の層に付ける名前(ダッシュボードの玉ねぎタイムラインに出る)
|
|
18
|
-
const INNER_NAME = {
|
|
19
|
-
route: "handler",
|
|
20
|
-
notFound: "notFound",
|
|
21
|
-
methodNotAllowed: "405",
|
|
22
|
-
options: "options",
|
|
23
|
-
badRequest: "400",
|
|
24
|
-
notImplemented: "501",
|
|
25
|
-
};
|
|
5
|
+
import { routeHandler } from "./validate.js";
|
|
6
|
+
import { groupEntries, joinPath } from "./group.js";
|
|
7
|
+
import { defaultNotFound, defaultOnError, checkQueryRequest, handleError, HttpError, markConversionError, methodNotAllowed, senriError, } from "./errors.js";
|
|
26
8
|
// 扱わないメソッド。ルートがあってもなくても 501 にする(app.all() でも受けない)
|
|
27
9
|
const NOT_IMPLEMENTED = new Set(["CONNECT", "TRACE", "TRACK"]);
|
|
28
10
|
// app.route(prefix, app) でつなげるように、アプリに登録されたものを横に置いておく
|
|
29
11
|
const appEntries = new WeakMap();
|
|
12
|
+
// 0.1 の createApp({ dev: true }) は使えなくなった。どう書き換えるかを案内する
|
|
13
|
+
const DEV_TRUE = `createApp({ dev: true }) is no longer supported. Use devtools() from "@senrijs/senri/dev".
|
|
14
|
+
createApp({ dev: true }) は使えなくなりました。@senrijs/senri/dev の devtools() を渡してください。
|
|
15
|
+
|
|
16
|
+
import { createApp } from "@senrijs/senri";
|
|
17
|
+
import { devtools } from "@senrijs/senri/dev";
|
|
18
|
+
|
|
19
|
+
const app = createApp({ dev: devtools() });`;
|
|
30
20
|
// V はアプリの型。c.set / c.get で使う変数の型(createApp<{ user: User }>())か、
|
|
31
21
|
// Workers の env も含めた形(createApp<{ Bindings: Env; Variables: { user: User } }>())で指定する(src/context.ts)
|
|
32
22
|
export function createApp(options = {}) {
|
|
33
|
-
|
|
23
|
+
if (options.dev === true)
|
|
24
|
+
throw senriError("dev_true", DEV_TRUE);
|
|
34
25
|
const routes = [];
|
|
35
26
|
const matcher = createMatcher();
|
|
36
27
|
// prefix が null なら全部のリクエストで動く。["admin"] なら /admin とその下だけ
|
|
37
28
|
// anonymous: 無名の関数だった(名前を anonymous#番号 にした)。ダッシュボードで「名前を付けると出る」と案内する
|
|
38
29
|
const middlewares = [];
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
30
|
+
// 開発モードの処理。createApp の最後に devtools().install() で入る(渡さなければ undefined のまま)
|
|
31
|
+
let devHooks;
|
|
32
|
+
let onError = defaultOnError;
|
|
33
|
+
let notFound = defaultNotFound;
|
|
34
|
+
// 登録時のエラーに、開発モードでは日本語の説明を付ける
|
|
35
|
+
function guard(fn) {
|
|
36
|
+
return (...args) => {
|
|
37
|
+
try {
|
|
38
|
+
fn(...args);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw devHooks ? devHooks.explain(err) : err;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
48
45
|
function method(name) {
|
|
49
|
-
return ((path, a, b) => add(name, path, routeHandler(path, a, b)));
|
|
46
|
+
return guard((path, a, b) => add(name, path, routeHandler(path, a, b), typeof a === "function" ? undefined : a));
|
|
50
47
|
}
|
|
51
|
-
function add(rawMethod, path, handler) {
|
|
48
|
+
function add(rawMethod, path, handler, schemas) {
|
|
52
49
|
// app.all() の印の "ALL" だけはそのまま。ほかは名前を確かめて大文字にする
|
|
53
50
|
const method = rawMethod === "ALL" ? rawMethod : normalizeMethod(rawMethod);
|
|
51
|
+
devHooks?.checkRoute(method, path, schemas);
|
|
54
52
|
const segments = splitPattern(path);
|
|
55
53
|
checkParamNames(path, segments);
|
|
56
|
-
// 開発モードでは /_senri 以下はダッシュボードが使うので、登録しても呼ばれない。気づけるようにエラーにする
|
|
57
|
-
if (dev && segments[0] === "_senri") {
|
|
58
|
-
throw new Error(`${method} ${path}: /_senri 以下は、開発モードではダッシュボード用に予約されています`);
|
|
59
|
-
}
|
|
60
54
|
// 同じメソッド + 同じ形のパスがあったら、あとから登録したほうは絶対に呼ばれないのでエラーにする
|
|
61
55
|
const shape = routeShape(segments);
|
|
62
56
|
const dup = routes.find((r) => r.method === method && routeShape(r.segments) === shape);
|
|
63
|
-
if (dup)
|
|
64
|
-
|
|
65
|
-
throw new Error(samePath
|
|
66
|
-
? `${method} ${path} はすでに登録されています`
|
|
67
|
-
: `${method} ${path} は、登録済みの ${dup.method} ${dup.path} と同じパスです(パラメーター名が違うだけです)`);
|
|
68
|
-
}
|
|
57
|
+
if (dup)
|
|
58
|
+
throw senriError("route_duplicate", `Duplicate route: ${method} ${path} (already: ${dup.method} ${dup.path})`);
|
|
69
59
|
const paramNames = segments.filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
70
60
|
const route = { path, segments, paramNames, method, handler };
|
|
71
|
-
// routes は一覧(routeList や 404 の「もしかして」)
|
|
61
|
+
// routes は一覧(routeList や 404 の「もしかして」)用で、登録順。探すのは matcher(Trie)で行う
|
|
72
62
|
routes.push(route);
|
|
73
|
-
routes.sort(compareRoutes);
|
|
74
63
|
matcher.add(route);
|
|
75
64
|
}
|
|
76
65
|
// マッチしたルートのパラメーターを、ルートごとの名前で組み立てる
|
|
@@ -144,11 +133,11 @@ export function createApp(options = {}) {
|
|
|
144
133
|
return layers;
|
|
145
134
|
}
|
|
146
135
|
const app = {
|
|
147
|
-
use(pathOrMw, mw) {
|
|
136
|
+
use: guard((pathOrMw, mw) => {
|
|
148
137
|
const path = typeof pathOrMw === "string" ? pathOrMw : null;
|
|
149
138
|
const fn = typeof pathOrMw === "string" ? mw : pathOrMw;
|
|
150
139
|
if (!fn)
|
|
151
|
-
throw
|
|
140
|
+
throw senriError("no_middleware", `Missing middleware: ${path}`);
|
|
152
141
|
middlewares.push({
|
|
153
142
|
// 関数の名前を使う。無名の関数(その場で書いたアロー関数など)は登録順の番号で呼ぶ
|
|
154
143
|
name: fn.name || `anonymous#${middlewares.length + 1}`,
|
|
@@ -157,7 +146,7 @@ export function createApp(options = {}) {
|
|
|
157
146
|
prefix: path === null ? null : splitPattern(path),
|
|
158
147
|
mw: fn,
|
|
159
148
|
});
|
|
160
|
-
},
|
|
149
|
+
}),
|
|
161
150
|
onError(handler) {
|
|
162
151
|
onError = handler;
|
|
163
152
|
},
|
|
@@ -178,38 +167,37 @@ export function createApp(options = {}) {
|
|
|
178
167
|
all: method("ALL"),
|
|
179
168
|
// 任意のメソッド(QUERY や、PROPFIND などの WebDAV)を登録する。配列でまとめて登録もできる
|
|
180
169
|
// 小文字で書いても大文字にする。CONNECT / TRACE / TRACK は扱わない
|
|
181
|
-
on: ((methods, path, a, b) => {
|
|
170
|
+
on: guard((methods, path, a, b) => {
|
|
182
171
|
const list = typeof methods === "string" ? [methods] : methods;
|
|
183
172
|
if (list.length === 0)
|
|
184
|
-
throw
|
|
173
|
+
throw senriError("no_method", `Missing method: ${path}`);
|
|
185
174
|
const handler = routeHandler(path, a, b);
|
|
186
175
|
for (const m of list) {
|
|
187
176
|
if (m.toUpperCase() === "ALL")
|
|
188
|
-
throw
|
|
189
|
-
add(m, path, handler);
|
|
177
|
+
throw senriError("on_all", "Use app.all() for every method");
|
|
178
|
+
add(m, path, handler, typeof a === "function" ? undefined : a);
|
|
190
179
|
}
|
|
191
180
|
}),
|
|
192
181
|
// K はテーブルのキーから推論される。各キーのパスからパラメーターの型が決まる
|
|
193
|
-
routes(table) {
|
|
182
|
+
routes: guard((table) => {
|
|
194
183
|
for (const [key, handler] of Object.entries(table)) {
|
|
195
184
|
const [method, path] = key.split(" ");
|
|
196
|
-
if (!method || !path)
|
|
197
|
-
throw
|
|
198
|
-
}
|
|
185
|
+
if (!method || !path)
|
|
186
|
+
throw senriError("route_key", `Invalid route key: "${key}" (e.g. "GET /hello")`);
|
|
199
187
|
add(method.toUpperCase(), path, handler);
|
|
200
188
|
}
|
|
201
|
-
},
|
|
189
|
+
}),
|
|
202
190
|
// createRouter() で作ったルーターをつなげる。ルーターに登録済みのものが、この時点でアプリに入る
|
|
203
191
|
// app.route(prefix, app) なら、別のアプリをプレフィックス付きでつなげる
|
|
204
192
|
// (つなぐ側のアプリでは、プレフィックスのパラメーターに型は付かない。型が必要なら createRouter を使う)
|
|
205
193
|
// つないだアプリの onError / notFound は使わず、こちらのものを使う
|
|
206
|
-
route(routerOrPrefix, child) {
|
|
194
|
+
route: guard((routerOrPrefix, child) => {
|
|
207
195
|
// 先に全部読んでおく(ルーターの作りがおかしいときに、途中まで登録された状態にしない)
|
|
208
196
|
let list;
|
|
209
197
|
if (typeof routerOrPrefix === "string") {
|
|
210
198
|
const read = child ? appEntries.get(child) : undefined;
|
|
211
199
|
if (!read)
|
|
212
|
-
throw new TypeError(`
|
|
200
|
+
throw senriError("not_app", new TypeError(`Not an app from createApp(): ${routerOrPrefix}`));
|
|
213
201
|
list = read().map((entry) => ({ ...entry, path: joinPath(routerOrPrefix, entry.path) }));
|
|
214
202
|
}
|
|
215
203
|
else {
|
|
@@ -221,7 +209,7 @@ export function createApp(options = {}) {
|
|
|
221
209
|
else
|
|
222
210
|
add(entry.method, entry.path, entry.handler);
|
|
223
211
|
}
|
|
224
|
-
},
|
|
212
|
+
}),
|
|
225
213
|
middlewareList() {
|
|
226
214
|
return middlewares.map((m) => ({ name: m.name, path: m.path, anonymous: m.anonymous }));
|
|
227
215
|
},
|
|
@@ -232,9 +220,9 @@ export function createApp(options = {}) {
|
|
|
232
220
|
// (形で見分けるので、Cloudflare Workers の env のような別のものが来ても壊れない。src/dev/dashboard/access.ts)
|
|
233
221
|
// ctx: Cloudflare Workers の3つ目の引数(c.executionCtx)。export default app で、Workers がそのまま呼ぶ
|
|
234
222
|
fetch(req, info, ctx) {
|
|
235
|
-
//
|
|
236
|
-
if (
|
|
237
|
-
return
|
|
223
|
+
// 開発モードは計測・記録をするので別の道を通る(@senrijs/senri/dev)
|
|
224
|
+
if (devHooks)
|
|
225
|
+
return devHooks.fetch(req, info, ctx);
|
|
238
226
|
try {
|
|
239
227
|
const c = new Context(req, info, ctx);
|
|
240
228
|
const resolved = resolve(c);
|
|
@@ -274,7 +262,7 @@ export function createApp(options = {}) {
|
|
|
274
262
|
const route = resolved.route;
|
|
275
263
|
// QUERY として登録したルートだけ、ボディと Content-Type を確かめる(app.all はハンドラーに任せる)
|
|
276
264
|
if (route.method === "QUERY") {
|
|
277
|
-
return checkQueryRequest(c
|
|
265
|
+
return checkQueryRequest(c).then((invalid) => {
|
|
278
266
|
if (invalid)
|
|
279
267
|
throw invalid;
|
|
280
268
|
return callHandler(c, route);
|
|
@@ -285,7 +273,7 @@ export function createApp(options = {}) {
|
|
|
285
273
|
case "options":
|
|
286
274
|
return new Response(null, { status: 204, headers: { allow: resolved.allow } });
|
|
287
275
|
case "methodNotAllowed":
|
|
288
|
-
throw methodNotAllowed(
|
|
276
|
+
throw methodNotAllowed(resolved.allow);
|
|
289
277
|
case "notImplemented":
|
|
290
278
|
throw new HttpError(501, "501 Not Implemented");
|
|
291
279
|
case "notFound":
|
|
@@ -315,61 +303,23 @@ export function createApp(options = {}) {
|
|
|
315
303
|
return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
316
304
|
})();
|
|
317
305
|
}
|
|
318
|
-
//
|
|
319
|
-
async function devFetch(req, info, ctx) {
|
|
320
|
-
// 計測の基準になるリクエストの開始時刻
|
|
321
|
-
const origin = performance.now();
|
|
322
|
-
const time = Date.now();
|
|
323
|
-
const c = new Context(req, info, ctx);
|
|
324
|
-
const dashboard = dashboardPromise ? await dashboardPromise : null;
|
|
325
|
-
// /_senri 以下はダッシュボード。localhost から開いたときだけ、ミドルウェアを通さずに処理する
|
|
326
|
-
// (開けないときは普通の処理に回すので、/_senri はないものとして 404 になる)。どちらも記録はしない
|
|
327
|
-
const dashboardPath = underDashboard(c);
|
|
328
|
-
if (dashboard && dashboardPath && dashboard.canOpen(req, info)) {
|
|
329
|
-
return dashboard.handle(req, dashboardPath);
|
|
330
|
-
}
|
|
331
|
-
if (!dashboardPath)
|
|
332
|
-
dashboard?.begin(c, info);
|
|
333
|
-
const fail = (err) => handleError(err, c, onError);
|
|
334
|
-
// ミドルウェアより先にマッチして、c.route / c.params を見られるようにしておく
|
|
335
|
-
const resolved = resolve(c);
|
|
336
|
-
if (resolved.kind === "route") {
|
|
337
|
-
c.route = resolved.route.path;
|
|
338
|
-
c.params = resolved.params;
|
|
339
|
-
}
|
|
340
|
-
// ハンドラー・notFound・toResponse のどこで投げても、compose が onError に集める
|
|
341
|
-
const layers = applicableMiddlewares(c);
|
|
342
|
-
const innerName = INNER_NAME[resolved.kind];
|
|
343
|
-
const measure = { timings: c.timings, origin, innerName };
|
|
344
|
-
const res = await compose(layers, c, () => inner(c, resolved), fail, measure);
|
|
345
|
-
// ミドルウェアを全部通ったあとなので、ここなら「通った順番」が揃っている
|
|
346
|
-
const tools = await devTools;
|
|
347
|
-
const page = await tools.finishErrorPage(c, res);
|
|
348
|
-
// 層ごとの時間を Server-Timing に載せる(ブラウザの開発者ツールで見られる)
|
|
349
|
-
const desc = resolved.kind === "route" ? `${resolved.route.method} ${resolved.route.path}` : innerName;
|
|
350
|
-
const final = tools.withServerTiming(c.finalize(page), tools.serverTimingHeader(c.timings, { name: innerName, desc }, performance.now() - origin));
|
|
351
|
-
// ダッシュボードに記録する(失敗してもレスポンスには影響しない)
|
|
352
|
-
await dashboard?.record(c, final, time, [
|
|
353
|
-
...layers.map((l) => ({ name: l.name, order: l.order ?? null })),
|
|
354
|
-
{ name: innerName, order: null },
|
|
355
|
-
]);
|
|
356
|
-
return final;
|
|
357
|
-
}
|
|
358
|
-
// ミドルウェアを先に(登録順のまま)、ルートはそのあと。ルートの順番は add() が並べ直すので関係ない
|
|
306
|
+
// ミドルウェアを先に、ルートはそのあと(どちらも登録順)
|
|
359
307
|
appEntries.set(app, () => [
|
|
360
308
|
...middlewares.map((m) => ({ kind: "use", path: m.path ?? "/", mw: m.mw })),
|
|
361
309
|
...routes.map((r) => ({ kind: "route", method: r.method, path: r.path, handler: r.handler })),
|
|
362
310
|
]);
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
311
|
+
// 開発モード: 中身を渡して、開発用の fetch と既定の onError / notFound を受け取る
|
|
312
|
+
if (options.dev) {
|
|
313
|
+
devHooks = options.dev.install({
|
|
314
|
+
app,
|
|
315
|
+
resolve,
|
|
316
|
+
layers: applicableMiddlewares,
|
|
317
|
+
inner: inner,
|
|
318
|
+
fail: (err, c) => handleError(err, c, onError),
|
|
319
|
+
routes: () => routes,
|
|
370
320
|
});
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
321
|
+
onError = devHooks.onError;
|
|
322
|
+
notFound = devHooks.notFound;
|
|
323
|
+
}
|
|
374
324
|
return app;
|
|
375
325
|
}
|
package/dist/src/compose.d.ts
CHANGED
|
@@ -14,9 +14,9 @@ export type Timing = {
|
|
|
14
14
|
start: number;
|
|
15
15
|
stopped: boolean;
|
|
16
16
|
};
|
|
17
|
-
export type
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
export type Trace = {
|
|
18
|
+
enter(i: number): void;
|
|
19
|
+
down(i: number): () => void;
|
|
20
|
+
leave(i: number, failed: boolean, stopped: boolean): void;
|
|
21
21
|
};
|
|
22
|
-
export declare function compose(layers: Layer[], c: Context, handle: () => Response | Promise<Response>, onError: (err: unknown) => Promise<Response>,
|
|
22
|
+
export declare function compose(layers: Layer[], c: Context, handle: () => Response | Promise<Response>, onError: (err: unknown) => Promise<Response>, trace?: Trace): Promise<Response>;
|
package/dist/src/compose.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { markConversionError } from "./errors.js";
|
|
1
|
+
import { markConversionError, senriError } from "./errors.js";
|
|
2
2
|
// 返した値をレスポンスにする。失敗したら、どこが返した値だったかをエラーに覚えさせてから投げ直す
|
|
3
3
|
export function toResponseOf(c, value, origin) {
|
|
4
4
|
try {
|
|
@@ -10,10 +10,9 @@ export function toResponseOf(c, value, origin) {
|
|
|
10
10
|
}
|
|
11
11
|
// ミドルウェアを順に通して、最後に handle を呼ぶ
|
|
12
12
|
// 例外は(ミドルウェアでもハンドラーでも)onError に渡してレスポンスにしてもらう
|
|
13
|
-
|
|
14
|
-
export function compose(layers, c, handle, onError, measure) {
|
|
13
|
+
export function compose(layers, c, handle, onError, trace) {
|
|
15
14
|
// 近道: ミドルウェアがなく、計測もしないなら、いちばん内側の処理を呼ぶだけ(下の dispatch と同じ結果になる)
|
|
16
|
-
if (layers.length === 0 && !
|
|
15
|
+
if (layers.length === 0 && !trace) {
|
|
17
16
|
try {
|
|
18
17
|
return Promise.resolve(handle()).catch(onError);
|
|
19
18
|
}
|
|
@@ -21,9 +20,6 @@ export function compose(layers, c, handle, onError, measure) {
|
|
|
21
20
|
return onError(err);
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
|
-
const timings = measure?.timings;
|
|
25
|
-
// 計測しないときは時計を読まない
|
|
26
|
-
const now = timings ? () => performance.now() : () => 0;
|
|
27
23
|
// どの層で例外が起きたか(層の番号)
|
|
28
24
|
let failedAt = -1;
|
|
29
25
|
const fail = (err, i) => {
|
|
@@ -31,29 +27,17 @@ export function compose(layers, c, handle, onError, measure) {
|
|
|
31
27
|
return onError(err);
|
|
32
28
|
};
|
|
33
29
|
const dispatch = async (i) => {
|
|
34
|
-
|
|
35
|
-
// next() の中(下流)で待っていた時間
|
|
36
|
-
let inner = 0;
|
|
30
|
+
trace?.enter(i);
|
|
37
31
|
// next() を呼ばずに値を返して終わったか
|
|
38
32
|
let stopped = false;
|
|
39
33
|
try {
|
|
40
|
-
return await step(i, (
|
|
34
|
+
return await step(i, () => (stopped = true));
|
|
41
35
|
}
|
|
42
36
|
finally {
|
|
43
|
-
|
|
44
|
-
const total = now() - start;
|
|
45
|
-
measure.timings[i] = {
|
|
46
|
-
name: layers[i]?.name ?? measure.innerName,
|
|
47
|
-
total,
|
|
48
|
-
self: total - inner,
|
|
49
|
-
failed: failedAt === i,
|
|
50
|
-
start: start - measure.origin,
|
|
51
|
-
stopped,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
37
|
+
trace?.leave(i, failedAt === i, stopped);
|
|
54
38
|
}
|
|
55
39
|
};
|
|
56
|
-
const step = async (i,
|
|
40
|
+
const step = async (i, markStopped) => {
|
|
57
41
|
const layer = layers[i];
|
|
58
42
|
if (!layer) {
|
|
59
43
|
try {
|
|
@@ -68,12 +52,13 @@ export function compose(layers, c, handle, onError, measure) {
|
|
|
68
52
|
let result;
|
|
69
53
|
try {
|
|
70
54
|
result = await layer.mw(c, () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
throw
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
55
|
+
// 2回目を許すと、ハンドラーや後ろのミドルウェアが2回動いてしまう
|
|
56
|
+
if (downstream)
|
|
57
|
+
throw senriError("next_twice", "next() called twice");
|
|
58
|
+
// 下流に入る時刻は、dispatch を始める前に取る(下流が最初の await までに同期で使った時間も、下流の時間にする)
|
|
59
|
+
const up = trace?.down(i);
|
|
60
|
+
const d = dispatch(i + 1);
|
|
61
|
+
downstream = up ? d.finally(up) : d;
|
|
77
62
|
return downstream;
|
|
78
63
|
});
|
|
79
64
|
// toResponse も JSON.stringify で投げることがあるので try の中で
|
|
@@ -89,7 +74,7 @@ export function compose(layers, c, handle, onError, measure) {
|
|
|
89
74
|
catch (err) {
|
|
90
75
|
return fail(err, i);
|
|
91
76
|
}
|
|
92
|
-
return fail(
|
|
77
|
+
return fail(senriError("no_response", "Middleware returned nothing and did not call next()"), i);
|
|
93
78
|
};
|
|
94
79
|
return dispatch(0);
|
|
95
80
|
}
|