@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 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.ip` は、Cloudflare が付ける `CF-Connecting-IP` を読みます(Workers で動いているときだけ)
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"); // "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)
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
- - `c.ip` はランタイムが渡す接続元を使います。`X-Forwarded-For` などのヘッダーは偽装できるので読みません。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読んでください
173
- - `c.setCookie(name, value, options)` の既定は `Path=/`・`HttpOnly`・`SameSite=Lax` です
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
- const app = createApp({ dev: true });
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
- 開発モードのときだけ、次のものが動きます。本番(既定)では、固定の文言(`500 Internal Server Error` など)だけを返し、計測も記録もしません。
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
- 開発モードを決めるのに環境変数(`NODE_ENV` など)は読みません。本番では `dev: true` にしないでください。
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 type { RouteSchemas, Validated, ValidatedParams } from "./validate.ts";
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?: boolean;
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 { compareRoutes, allowedMethods, checkParamNames, normalizeMethod, routeShape, splitPattern, startsWithSegments, } from "./router.js";
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 { isWorkers } from "./runtime.js";
6
- import { groupEntries, joinPath, routeHandler } from "./group.js";
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
- const dev = options.dev ?? false;
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
- // 開発モードだけで使うもの(エラーページ、ダッシュボード)。ビルド済みの HTML など大きいので、
40
- // 本番では読み込まないよう dev のときだけ import() する(src/dev/index.ts)
41
- const devTools = dev ? import("./dev/index.js") : null;
42
- // 開発モードでは、ブラウザから開いた 5xx を HTML のエラーページにする(src/dev/pages.ts)
43
- const baseOnError = defaultOnError(dev);
44
- let onError = devTools
45
- ? async (err, c) => (await devTools).withErrorPage(baseOnError, true)(err, c)
46
- : baseOnError;
47
- let notFound = defaultNotFound(routes, dev);
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
- const samePath = dup.segments.join("/") === segments.join("/");
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 の「もしかして」)用。探すのは matcher(Trie)で行う
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 new Error(`app.use("${path}", ...) にミドルウェアがありません`);
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 new Error(`app.on(..., "${path}") にメソッドがありません`);
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 new Error("すべてのメソッドで動かすには app.all() を使ってください");
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 new Error(`ルートの書き方が違います: "${key}"\n例: "GET /hello"`);
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(`app.route("${routerOrPrefix}", app) には createApp() で作ったアプリを渡してください`);
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 (devTools)
237
- return devFetch(req, info, ctx);
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, dev).then((invalid) => {
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(c, c.path, resolved.allow, dev);
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
- // 開発モードの fetch。層ごとの時間を測り、エラーページ・Server-Timing・ダッシュボードの記録を付ける
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
- // Cloudflare Workers では作らない(接続元が分からず localhost 制限で開けないうえ、
365
- // リクエストごとに別の isolate で動くことがあり、記録が残らない)。/_senri はふつうの 404 になる
366
- const dashboardPromise = isWorkers() ? null : devTools?.then((m) => {
367
- const dashboard = m.createDashboard({
368
- routes: () => app.routeList(),
369
- middlewares: () => app.middlewareList(),
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
- m.registerDashboard(app, dashboard);
372
- return dashboard;
373
- });
321
+ onError = devHooks.onError;
322
+ notFound = devHooks.notFound;
323
+ }
374
324
  return app;
375
325
  }
@@ -14,9 +14,9 @@ export type Timing = {
14
14
  start: number;
15
15
  stopped: boolean;
16
16
  };
17
- export type Measure = {
18
- timings: Timing[];
19
- origin: number;
20
- innerName: string;
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>, measure?: Measure): Promise<Response>;
22
+ export declare function compose(layers: Layer[], c: Context, handle: () => Response | Promise<Response>, onError: (err: unknown) => Promise<Response>, trace?: Trace): Promise<Response>;
@@ -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
- // measure を渡したときだけ、各層の時間を timings[i] に記録する(i = 層の順番、最後がいちばん内側)
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 && !measure) {
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
- const start = now();
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, (ms) => (inner += ms), () => (stopped = true));
34
+ return await step(i, () => (stopped = true));
41
35
  }
42
36
  finally {
43
- if (measure) {
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, addInner, markStopped) => {
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
- if (downstream) {
72
- // 2回目を許すと、ハンドラーや後ろのミドルウェアが2回動いてしまう
73
- throw new Error("next() が2回呼ばれました。1つのミドルウェアで呼べるのは1回だけです");
74
- }
75
- const nextStart = now();
76
- downstream = dispatch(i + 1).finally(() => addInner(now() - nextStart));
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(new Error("ミドルウェアが何も返さず、next()もよんでいません"), i);
77
+ return fail(senriError("no_response", "Middleware returned nothing and did not call next()"), i);
93
78
  };
94
79
  return dispatch(0);
95
80
  }