@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.
@@ -1,5 +1,4 @@
1
1
  import type { Timing } from "./compose.ts";
2
- import { type CookieOptions } from "./cookie.ts";
3
2
  export type BodyValue = Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer> | ReadableStream | FormData | URLSearchParams;
4
3
  export type JsonObject = object & {
5
4
  call?: never;
@@ -26,6 +25,10 @@ export type Handler<P = Record<string, string>, V extends object = any, I extend
26
25
  export type Next = () => Promise<Response>;
27
26
  export type Middleware<V extends object = any> = (c: Context<V>, next: Next) => HandlerResult | void | Promise<HandlerResult | void>;
28
27
  export declare function setValidated(c: Context, target: string, value: unknown): void;
28
+ export declare let connectionOf: (c: Context) => {
29
+ req: Request;
30
+ info: unknown;
31
+ };
29
32
  export declare class Context<V extends object = any, I extends object = {}> {
30
33
  #private;
31
34
  req: Request;
@@ -35,7 +38,6 @@ export declare class Context<V extends object = any, I extends object = {}> {
35
38
  constructor(req: Request, info?: unknown, ctx?: ExecutionContext);
36
39
  get env(): BindingsOf<V>;
37
40
  get executionCtx(): ExecutionContext;
38
- get ip(): string | null;
39
41
  get segments(): readonly string[];
40
42
  get path(): string;
41
43
  get timings(): Timing[];
@@ -46,10 +48,6 @@ export declare class Context<V extends object = any, I extends object = {}> {
46
48
  query(name: string): string | undefined;
47
49
  queries(name: string): string[];
48
50
  body<T = unknown>(): Promise<T>;
49
- cookie(): Record<string, string>;
50
- cookie(name: string): string | undefined;
51
- setCookie(name: string, value: string, options?: CookieOptions): this;
52
- deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): this;
53
51
  status(code: number): this;
54
52
  header(name: string, value: string): this;
55
53
  json(data: unknown, status?: number): Response;
@@ -1,7 +1,7 @@
1
1
  import { requestPathname, splitPath } from "./router.js";
2
- import { clientIp, isConnectionInfo } from "./connection.js";
2
+ import { isConnectionInfo } from "./connection.js";
3
3
  import { readBody } from "./body.js";
4
- import { deletionCookie, parseCookies, serializeCookie } from "./cookie.js";
4
+ import { senriError } from "./errors.js";
5
5
  function isBodyValue(value) {
6
6
  return (value instanceof Blob ||
7
7
  value instanceof ArrayBuffer ||
@@ -36,6 +36,9 @@ export function setValidated(c, target, value) {
36
36
  validated.set(c, (values = {}));
37
37
  values[target] = value;
38
38
  }
39
+ // app.fetch に渡された元のリクエストと2つ目の引数を読む(Context の外から。@senrijs/senri/ip で使う)
40
+ // Context のメソッドにしないのは、使わないアプリのバンドルに入れないため
41
+ export let connectionOf;
39
42
  export class Context {
40
43
  req;
41
44
  #status = 200;
@@ -50,7 +53,6 @@ export class Context {
50
53
  // 一度読んだボディ(ミドルウェアとハンドラーの両方から読めるように覚えておく)
51
54
  #body;
52
55
  #query;
53
- #cookies;
54
56
  error = undefined;
55
57
  // マッチしたルートのパターン("/users/:id")。見つからなければ undefined
56
58
  // ミドルウェアより先にマッチするので、next() の前から読める(ログやダッシュボードの集計用)
@@ -58,11 +60,13 @@ export class Context {
58
60
  // パスパラメーター。ハンドラーでは第1引数でも受け取れる
59
61
  params = {};
60
62
  #timings;
61
- // app.fetch に渡された元のリクエストと2つ目の引数(c.ip で使う)
63
+ // app.fetch に渡された元のリクエストと2つ目の引数(@senrijs/senri/ip の getIp で使う)
62
64
  // Bun の requestIP は元のリクエストでしか答えないので、c.req が差し替えられても元のほうで聞く
63
65
  #original;
64
66
  #info;
65
- #ip;
67
+ static {
68
+ connectionOf = (c) => ({ req: c.#original, info: c.#info });
69
+ }
66
70
  #ctx;
67
71
  constructor(req, info, ctx) {
68
72
  this.req = req;
@@ -78,17 +82,10 @@ export class Context {
78
82
  // Cloudflare Workers の ctx(waitUntil など)。Workers 以外では渡されないので、読むとエラーにする
79
83
  get executionCtx() {
80
84
  if (!this.#ctx) {
81
- throw new Error("c.executionCtx がありません。Cloudflare Workers で、app.fetch(req, env, ctx) として呼ばれたときだけ使えます");
85
+ throw senriError("no_execution_ctx", "c.executionCtx is only available on Cloudflare Workers");
82
86
  }
83
87
  return this.#ctx;
84
88
  }
85
- // 接続元の IP アドレス。分からなければ null(Cloudflare Workers や、app.fetch を直接呼んだときなど)
86
- // X-Forwarded-For などのヘッダーは偽装できるので読まない。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読む
87
- get ip() {
88
- if (this.#ip === undefined)
89
- this.#ip = clientIp(this.#original, this.#info);
90
- return this.#ip;
91
- }
92
89
  // パスをデコードして "/" で分けたもの。ルーターもミドルウェアもこれを見る
93
90
  // 初めて読んだときに1回だけ作る。エンコードが不正なら HttpError(400) を投げる
94
91
  get segments() {
@@ -122,7 +119,7 @@ export class Context {
122
119
  valid(target) {
123
120
  const values = validated.get(this);
124
121
  if (!values || !(target in values)) {
125
- throw new Error(`c.valid("${target}") の値がありません。ルートに { ${target}: スキーマ } を渡すと使えます`);
122
+ throw senriError("no_valid", `c.valid("${target}"): no schema for ${target}`);
126
123
  }
127
124
  return values[target];
128
125
  }
@@ -159,20 +156,6 @@ export class Context {
159
156
  this.#body ??= readBody(this.req);
160
157
  return this.#body;
161
158
  }
162
- cookie(name) {
163
- this.#cookies ??= parseCookies(this.req.headers.get("cookie"));
164
- return name === undefined ? { ...this.#cookies } : this.#cookies[name];
165
- }
166
- // 既定は Path=/・HttpOnly・SameSite=Lax(src/cookie.ts)
167
- setCookie(name, value, options) {
168
- this.header("set-cookie", serializeCookie(name, value, options));
169
- return this;
170
- }
171
- // Path と Domain は、付けたときと同じものを指定する
172
- deleteCookie(name, options) {
173
- this.header("set-cookie", deletionCookie(name, options));
174
- return this;
175
- }
176
159
  status(code) {
177
160
  this.#status = code;
178
161
  return this;
@@ -237,17 +220,15 @@ export class Context {
237
220
  }
238
221
  if (isBodyValue(value)) {
239
222
  const contentType = this.#headers?.get("content-type") ?? contentTypeOf(value);
240
- return new Response(value, {
241
- status: this.#status,
242
- headers: contentType ? { "content-type": contentType } : undefined,
243
- });
223
+ // headers: undefined は渡さない(exactOptionalPropertyTypes の利用者の型チェックで、エラーになるので)
224
+ return new Response(value, contentType ? { status: this.#status, headers: { "content-type": contentType } } : { status: this.#status });
244
225
  }
245
226
  // 型では防いでいるが、JS から使ったときや as で無理やり通したときのために実行時にも確かめる
246
227
  if (typeof value === "function") {
247
- throw new TypeError("ハンドラーが関数を返しました。呼び出し忘れではありませんか?");
228
+ throw senriError("returned_function", new TypeError("Handler returned a function"));
248
229
  }
249
230
  if (value === undefined || value === null) {
250
- throw new TypeError("ハンドラーが何も返していません(undefined / null)");
231
+ throw senriError("returned_nothing", new TypeError("Handler returned undefined or null"));
251
232
  }
252
233
  return this.json(value);
253
234
  }
@@ -1,3 +1,4 @@
1
+ import type { Context } from "./context.ts";
1
2
  export type CookieOptions = {
2
3
  maxAge?: number;
3
4
  expires?: Date;
@@ -11,3 +12,7 @@ export type CookieOptions = {
11
12
  export declare function parseCookies(header: string | null): Record<string, string>;
12
13
  export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
13
14
  export declare function deletionCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): string;
15
+ export declare function getCookie(c: Context): Record<string, string>;
16
+ export declare function getCookie(c: Context, name: string): string | undefined;
17
+ export declare function setCookie(c: Context, name: string, value: string, options?: CookieOptions): void;
18
+ export declare function deleteCookie(c: Context, name: string, options?: Pick<CookieOptions, "path" | "domain">): void;
@@ -1,4 +1,4 @@
1
- // Cookie の読み書き(RFC 6265)
1
+ import { senriError } from "./errors.js";
2
2
  // Cookie の名前に使える文字(RFC 6265 の token)
3
3
  const NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
4
4
  // "Cookie: a=1; b=2" を { a: "1", b: "2" } に。値はデコードする(できなければそのまま)
@@ -28,7 +28,7 @@ export function parseCookies(header) {
28
28
  }
29
29
  export function serializeCookie(name, value, options = {}) {
30
30
  if (!NAME.test(name)) {
31
- throw new TypeError(`Cookie の名前に使えない文字があります: ${JSON.stringify(name)}`);
31
+ throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
32
32
  }
33
33
  const parts = [`${name}=${encodeURIComponent(value)}`];
34
34
  if (options.maxAge !== undefined)
@@ -51,7 +51,7 @@ export function serializeCookie(name, value, options = {}) {
51
51
  // 消すための Set-Cookie。Path と Domain は、付けたときと同じものを指定する
52
52
  export function deletionCookie(name, options = {}) {
53
53
  if (!NAME.test(name)) {
54
- throw new TypeError(`Cookie の名前に使えない文字があります: ${JSON.stringify(name)}`);
54
+ throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
55
55
  }
56
56
  const parts = [`${name}=`, "Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT"];
57
57
  if (options.domain)
@@ -59,3 +59,15 @@ export function deletionCookie(name, options = {}) {
59
59
  parts.push(`Path=${options.path ?? "/"}`);
60
60
  return parts.join("; ");
61
61
  }
62
+ export function getCookie(c, name) {
63
+ const all = parseCookies(c.req.headers.get("cookie"));
64
+ return name === undefined ? { ...all } : all[name];
65
+ }
66
+ // 既定は Path=/・HttpOnly・SameSite=Lax。最後に1回だけレスポンスに足される(c.header と同じ)
67
+ export function setCookie(c, name, value, options) {
68
+ c.header("set-cookie", serializeCookie(name, value, options));
69
+ }
70
+ // Path と Domain は、付けたときと同じものを指定する
71
+ export function deleteCookie(c, name, options) {
72
+ c.header("set-cookie", deletionCookie(name, options));
73
+ }
@@ -0,0 +1,2 @@
1
+ export declare function explainText(err: unknown): string | null;
2
+ export declare function explain(err: unknown): unknown;
@@ -0,0 +1,37 @@
1
+ // 本体が投げるエラー(短い英語 + code)に、開発モードで日本語の説明を付ける
2
+ // 本体を小さくするため、長い説明はこちらに置いている(src/errors.ts の senriError)
3
+ import { errorCode } from "../errors.js";
4
+ const JA = {
5
+ route_duplicate: "同じメソッド・同じ形のパスが、すでに登録されています(パラメーター名だけが違うものも同じとみなします)。あとから登録したほうは呼ばれません",
6
+ param_empty: "パラメーター名が空です(\":\" のあとに名前を書いてください)",
7
+ param_twice: "1つのパスで、同じパラメーター名が2回使われています",
8
+ method_invalid: "メソッド名に使えない文字があります",
9
+ method_unsupported: "CONNECT / TRACE / TRACK は扱いません(登録できません)",
10
+ no_handler: "ハンドラーがありません。app.get(path, { params, query, body }, handler) のように、スキーマのあとにハンドラーを渡してください",
11
+ no_middleware: "ミドルウェアがありません",
12
+ no_method: "メソッドがありません",
13
+ on_all: "すべてのメソッドで動かすには app.all() を使ってください",
14
+ route_key: "ルートの書き方が違います。\"GET /hello\" のように、メソッドとパスの間にスペースを入れてください",
15
+ not_app: "app.route(prefix, app) には createApp() で作ったアプリを渡してください",
16
+ not_router: "createRouter() で作ったルーターを渡してください",
17
+ router_cycle: "ルーターが自分自身を含んでいます",
18
+ cookie_name: "Cookie の名前に使えない文字があります",
19
+ no_valid: "c.valid() で読めるのは、ルートに { query } や { body } のスキーマを渡したものだけです",
20
+ no_execution_ctx: "c.executionCtx は Cloudflare Workers で、app.fetch(req, env, ctx) として呼ばれたときだけ使えます",
21
+ returned_function: "ハンドラーが関数を返しました。呼び出し忘れではありませんか?",
22
+ returned_nothing: "ハンドラーが何も返していません(undefined / null)",
23
+ next_twice: "next() が2回呼ばれました。1つのミドルウェアで呼べるのは1回だけです",
24
+ no_response: "ミドルウェアが何も返さず、next() も呼んでいません",
25
+ };
26
+ // 日本語の説明(なければ null)
27
+ export function explainText(err) {
28
+ const code = errorCode(err);
29
+ return code ? (JA[code] ?? null) : null;
30
+ }
31
+ // エラーの message の前に、日本語の説明を付ける(code や種類はそのまま)
32
+ export function explain(err) {
33
+ const ja = explainText(err);
34
+ if (ja && err instanceof Error && !err.message.startsWith(ja))
35
+ err.message = `${ja}\n${err.message}`;
36
+ return err;
37
+ }
@@ -1,3 +1,6 @@
1
+ // よくあるエラーに、直し方のヒントを付ける(ルールベース。当てはまらなければ何も出さない)
2
+ // エラーメッセージの書き方はランタイムで違う(Bun と、V8 の Node / Deno)ので、両方を見る
3
+ import { errorCode } from "../errors.js";
1
4
  // 利用者のコードが投げたエラーか(ランタイム内部のフレームは飛ばして、いちばん上を見る)
2
5
  // スタックが分からなければ、利用者のものとして扱う
3
6
  function thrownByUser(frames) {
@@ -16,16 +19,16 @@ function propertyOfNothing(message) {
16
19
  }
17
20
  const rules = [
18
21
  // Senri 自身が投げるエラー
19
- (err) => err.message.startsWith("next() が2回呼ばれました")
22
+ (err) => errorCode(err) === "next_twice"
20
23
  ? {
21
24
  text: "1つのミドルウェアで `next()` を2回呼んでいます。`await next()` は1回だけにして、結果を変数に入れて使ってください。",
22
25
  code: "const res = await next();",
23
26
  }
24
27
  : null,
25
- (err) => err.message.startsWith("ハンドラーが関数を返しました")
28
+ (err) => errorCode(err) === "returned_function"
26
29
  ? { text: "関数を呼び出さずに返しています。`()` を付けて呼び出した結果を返してください。", code: "return getUser();" }
27
30
  : null,
28
- (err) => err.message.startsWith("ハンドラーが何も返していません")
31
+ (err) => errorCode(err) === "returned_nothing"
29
32
  ? { text: "ハンドラーが値を返していません。`return` を忘れていないか確かめてください。", code: 'return "ok";' }
30
33
  : null,
31
34
  // undefined / null のプロパティを読んだ
@@ -1,6 +1,3 @@
1
- import type { Dashboard } from "./dashboard/index.ts";
2
- export { finishErrorPage, withErrorPage } from "./pages.ts";
3
- export { createDashboard, type Dashboard } from "./dashboard/index.ts";
4
- export { serverTimingHeader, withServerTiming } from "./server-timing.ts";
5
- export declare function registerDashboard(app: object, dashboard: Dashboard): void;
6
- export declare function dashboardOf(app: object): Promise<Dashboard | undefined>;
1
+ import type { DevHooks } from "../app.ts";
2
+ export declare function devtools(): DevHooks | undefined;
3
+ export declare function isLocalUrl(url: string): boolean;
@@ -1,11 +1,185 @@
1
- export { finishErrorPage, withErrorPage } from "./pages.js";
2
- export { createDashboard } from "./dashboard/index.js";
3
- export { serverTimingHeader, withServerTiming } from "./server-timing.js";
4
- // アプリとダッシュボードの対応(テストで中身を確かめるのに使う)
5
- const dashboards = new WeakMap();
6
- export function registerDashboard(app, dashboard) {
7
- dashboards.set(app, dashboard);
8
- }
9
- export async function dashboardOf(app) {
10
- return dashboards.get(app);
1
+ import { Context } from "../context.js";
2
+ import { compose } from "../compose.js";
3
+ import { defaultNotFound, defaultOnError, errorCode, HttpError } from "../errors.js";
4
+ import { compareRoutes } from "../router.js";
5
+ import * as runtime from "../runtime.js";
6
+ import { ValidationError } from "../validate.js";
7
+ import { createDashboard } from "./dashboard/index.js";
8
+ import { registerDashboard } from "./registry.js";
9
+ import { explain } from "./explain.js";
10
+ import { devNotFound } from "./not-found.js";
11
+ import { finishErrorPage, withErrorPage } from "./pages.js";
12
+ import { serverTimingHeader, withServerTiming } from "./server-timing.js";
13
+ import { checkSchemas, describeValidation } from "./validation.js";
14
+ // 計測で、いちばん内側の層に付ける名前(ダッシュボードの玉ねぎタイムラインに出る)
15
+ const INNER_NAME = {
16
+ route: "handler",
17
+ notFound: "notFound",
18
+ methodNotAllowed: "405",
19
+ options: "options",
20
+ badRequest: "400",
21
+ notImplemented: "501",
22
+ };
23
+ // 開発モードのフックを作る。本番(NODE_ENV=production)では undefined を返すので、開発モードにならない
24
+ // Deno で NODE_ENV を読む許可がないときは、本番かどうか分からないので、開発モードにしない(案内を1行出す)
25
+ export function devtools() {
26
+ if (!runtime.canReadEnv("NODE_ENV")) {
27
+ console.warn("[senri] devtools() is off: NODE_ENV is not readable. Run with --allow-env=NODE_ENV to enable it. / NODE_ENV を読めないので devtools() は無効です。--allow-env=NODE_ENV を付けると使えます");
28
+ return undefined;
29
+ }
30
+ if (runtime.env("NODE_ENV") === "production")
31
+ return undefined;
32
+ // Workers(nodejs_compat で process があっても、NODE_ENV はふつう入っていない)とブラウザは、本番かどうか分からない
33
+ const localOnly = runtime.isWorkers() || typeof globalThis.process === "undefined";
34
+ return { install: (core) => install(core, localOnly) };
35
+ }
36
+ // request.url のホスト名が localhost か(Host や X-Forwarded-Host などのヘッダーは見ない。だれでも付けられるので)
37
+ // - Workers: Cloudflare は登録されたドメインのリクエストしか渡さないので、本番に localhost は来ない。wrangler dev は localhost
38
+ // - ブラウザ: Service Worker が受けるリクエストの URL は、そのサイトのオリジン。開発中だけ localhost になる
39
+ export function isLocalUrl(url) {
40
+ let host;
41
+ try {
42
+ host = new URL(url).hostname.toLowerCase();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ return (host === "localhost" ||
48
+ host.endsWith(".localhost") ||
49
+ host === "[::1]" ||
50
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
51
+ }
52
+ // パスが /_senri か /_senri/... なら、"_senri" より後ろのセグメント。そうでなければ null
53
+ function underDashboard(c) {
54
+ try {
55
+ return c.segments[0] === "_senri" ? c.segments.slice(1) : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ // 層ごとの時間を c.timings に記録する(src/compose.ts の Trace)
62
+ function measure(c, origin, layers, innerName) {
63
+ const starts = [];
64
+ const inner = [];
65
+ return {
66
+ enter(i) {
67
+ starts[i] = performance.now();
68
+ inner[i] = 0;
69
+ },
70
+ down(i) {
71
+ const start = performance.now();
72
+ return () => (inner[i] += performance.now() - start);
73
+ },
74
+ leave(i, failed, stopped) {
75
+ const total = performance.now() - starts[i];
76
+ c.timings[i] = {
77
+ name: layers[i]?.name ?? innerName,
78
+ total,
79
+ self: total - inner[i],
80
+ failed,
81
+ start: starts[i] - origin,
82
+ stopped,
83
+ };
84
+ },
85
+ };
86
+ }
87
+ // 開発モードの既定の onError。本番の固定の文言の代わりに、詳しい文言を返す
88
+ // (ブラウザから開いた 5xx は、withErrorPage がエラーページにする)
89
+ const devOnError = (err, c) => {
90
+ if (err instanceof ValidationError)
91
+ return c.text(describeValidation(err));
92
+ if (err instanceof HttpError) {
93
+ switch (errorCode(err)) {
94
+ case "method_not_allowed":
95
+ return c.text(`405 Method Not Allowed: ${c.req.method} ${c.path}\n${err.headers.get("allow")} ならあります`);
96
+ case "query_no_body":
97
+ return c.text("400 Bad Request: QUERY リクエストにボディがありません\nQUERY は、検索の条件をボディに入れて送るメソッドです");
98
+ case "query_no_type":
99
+ return c.text("415 Unsupported Media Type: QUERY リクエストに Content-Type がありません\nボディの形式を指定してください(例: Content-Type: application/json)");
100
+ }
101
+ return c.text(err.message);
102
+ }
103
+ explain(err);
104
+ return c.text(`500 Internal Server Error: ${err instanceof Error ? err.message : String(err)}`);
105
+ };
106
+ // localOnly: localhost へのリクエストのときだけ開発用の出力をする(Workers とブラウザ)
107
+ function install(core, localOnly) {
108
+ const { app } = core;
109
+ // 本番と同じに振る舞うリクエスト(localOnly で、localhost でないもの)
110
+ const quiet = new WeakSet();
111
+ // 一覧は優先順位の順(静的なセグメントが先)。本体は登録順に持っている
112
+ const sortedRoutes = () => [...core.routes()].sort(compareRoutes);
113
+ // ダッシュボード。Cloudflare Workers では作らない(接続元が分からず localhost 制限で開けないうえ、
114
+ // リクエストごとに別の isolate で動くことがあり、記録が残らない)。/_senri はふつうの 404 になる
115
+ const dashboard = runtime.isWorkers()
116
+ ? null
117
+ : createDashboard({
118
+ routes: () => sortedRoutes().map((r) => ({ method: r.method, path: r.path })),
119
+ middlewares: () => app.middlewareList(),
120
+ });
121
+ if (dashboard)
122
+ registerDashboard(app, dashboard);
123
+ async function fetch(req, info, ctx) {
124
+ // 計測の基準になるリクエストの開始時刻
125
+ const origin = performance.now();
126
+ const time = Date.now();
127
+ const c = new Context(req, info, ctx);
128
+ if (localOnly && !isLocalUrl(req.url))
129
+ return quietFetch(c);
130
+ // /_senri 以下はダッシュボード。localhost から開いたときだけ、ミドルウェアを通さずに処理する
131
+ // (開けないときは普通の処理に回すので、/_senri はないものとして 404 になる)。どちらも記録はしない
132
+ const dashboardPath = underDashboard(c);
133
+ if (dashboard && dashboardPath && dashboard.canOpen(req, info)) {
134
+ return dashboard.handle(req, dashboardPath);
135
+ }
136
+ if (!dashboardPath)
137
+ dashboard?.begin(c, info);
138
+ // ミドルウェアより先にマッチして、c.route / c.params を見られるようにしておく
139
+ const resolved = core.resolve(c);
140
+ if (resolved.kind === "route") {
141
+ c.route = resolved.route.path;
142
+ c.params = resolved.params;
143
+ }
144
+ // ハンドラー・notFound・toResponse のどこで投げても、compose が onError に集める
145
+ const layers = core.layers(c);
146
+ const innerName = INNER_NAME[resolved.kind];
147
+ const res = await compose(layers, c, () => core.inner(c, resolved), (err) => core.fail(err, c), measure(c, origin, layers, innerName));
148
+ // ミドルウェアを全部通ったあとなので、ここなら「通った順番」が揃っている
149
+ const page = await finishErrorPage(c, res);
150
+ // 層ごとの時間を Server-Timing に載せる(ブラウザの開発者ツールで見られる)
151
+ const desc = resolved.kind === "route" ? `${resolved.route.method} ${resolved.route.path}` : innerName;
152
+ const final = withServerTiming(c.finalize(page), serverTimingHeader(c.timings, { name: innerName, desc }, performance.now() - origin));
153
+ // ダッシュボードに記録する(失敗してもレスポンスには影響しない)
154
+ await dashboard?.record(c, final, time, [
155
+ ...layers.map((l) => ({ name: l.name, order: l.order ?? null })),
156
+ { name: innerName, order: null },
157
+ ]);
158
+ return final;
159
+ }
160
+ // 本番の fetch と同じ流れ(計測・記録・エラーページ・Server-Timing なし。onError / notFound も本番の既定になる)
161
+ function quietFetch(c) {
162
+ quiet.add(c);
163
+ const resolved = core.resolve(c);
164
+ if (resolved.kind === "route") {
165
+ c.route = resolved.route.path;
166
+ c.params = resolved.params;
167
+ }
168
+ return compose(core.layers(c), c, () => core.inner(c, resolved), (err) => core.fail(err, c)).then((res) => c.finalize(res));
169
+ }
170
+ const pageOnError = withErrorPage(devOnError, true);
171
+ return {
172
+ fetch,
173
+ onError: (err, c) => (quiet.has(c) ? defaultOnError(err, c) : pageOnError(err, c)),
174
+ notFound: (c) => (quiet.has(c) ? defaultNotFound(c) : devNotFound(c, sortedRoutes())),
175
+ checkRoute(method, path, schemas) {
176
+ if (schemas)
177
+ checkSchemas(path, schemas);
178
+ // /_senri 以下はダッシュボードが使うので、登録しても呼ばれない。気づけるようにエラーにする
179
+ if (dashboard && /^\/*_senri(\/|$)/.test(path)) {
180
+ throw new Error(`${method} ${path}: /_senri 以下は、開発モードではダッシュボード用に予約されています`);
181
+ }
182
+ },
183
+ explain,
184
+ };
11
185
  }
@@ -0,0 +1,3 @@
1
+ import type { Context, HandlerResult } from "../context.ts";
2
+ import type { Route } from "../router.ts";
3
+ export declare function devNotFound(c: Context, routes: Route[]): HandlerResult | Promise<HandlerResult>;
@@ -0,0 +1,15 @@
1
+ import { notFoundPage } from "./not-found-page.js";
2
+ import { wantsHtml } from "./render.js";
3
+ import { suggest } from "./suggest.js";
4
+ export function devNotFound(c, routes) {
5
+ if (wantsHtml(c.req))
6
+ return notFoundPage(c, routes);
7
+ // ルートと同じく、デコード・正規化したパスで比べる(notFound に来るのはデコードできたリクエストだけ)
8
+ const path = c.path;
9
+ let message = `404 Not Found: ${c.req.method} ${path}`;
10
+ const closest = suggest(routes, path);
11
+ if (closest) {
12
+ message += `\nもしかして: ${closest.method} ${closest.path} ?`;
13
+ }
14
+ return c.text(message);
15
+ }
@@ -0,0 +1,3 @@
1
+ import type { Dashboard } from "./dashboard/index.ts";
2
+ export declare function registerDashboard(app: object, dashboard: Dashboard): void;
3
+ export declare function dashboardOf(app: object): Promise<Dashboard | undefined>;
@@ -0,0 +1,7 @@
1
+ const dashboards = new WeakMap();
2
+ export function registerDashboard(app, dashboard) {
3
+ dashboards.set(app, dashboard);
4
+ }
5
+ export async function dashboardOf(app) {
6
+ return dashboards.get(app);
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { RouteSchemas, ValidationError } from "../validate.ts";
2
+ export declare function describeValidation(err: ValidationError): string;
3
+ export declare function checkSchemas(path: string, schemas: RouteSchemas): void;
@@ -0,0 +1,47 @@
1
+ // バリデーションの、開発モードだけの部分(本体を小さくするため、本体の src/validate.ts から分けている)
2
+ // - 登録した時点で、スキーマの書き間違いに気づけるようにする
3
+ // - 通らなかったとき、どこが違うかを一覧にする
4
+ import { senriError } from "../errors.js";
5
+ const LABEL = {
6
+ params: "パスパラメーター",
7
+ query: "クエリ",
8
+ body: "ボディ",
9
+ };
10
+ // ["user", 0, "name"] → "user[0].name"
11
+ function issuePath(issue) {
12
+ let out = "";
13
+ for (const part of issue.path ?? []) {
14
+ const key = typeof part === "object" ? part.key : part;
15
+ if (typeof key === "number")
16
+ out += `[${key}]`;
17
+ else
18
+ out += out ? `.${String(key)}` : String(key);
19
+ }
20
+ return out;
21
+ }
22
+ // 開発モードで返す、どこが違うかの一覧
23
+ export function describeValidation(err) {
24
+ const lines = err.issues.map((issue) => {
25
+ const path = issuePath(issue);
26
+ return `- ${path ? `${path}: ` : ""}${issue.message}`;
27
+ });
28
+ return [`400 Bad Request: ${LABEL[err.target]}が、スキーマに合いません`, ...lines].join("\n");
29
+ }
30
+ // ArkType のスキーマは関数なので、オブジェクトでも関数でもよい
31
+ function isSchema(value) {
32
+ return (typeof value === "object" || typeof value === "function") && value !== null && "~standard" in value;
33
+ }
34
+ // スキーマを確かめておく(書き間違いに、登録した時点で気づけるように)
35
+ export function checkSchemas(path, schemas) {
36
+ if (typeof schemas !== "object" || schemas === null) {
37
+ throw senriError("schema_shape", new TypeError(`${path}: 2つ目の引数には { params, query, body } のスキーマかハンドラーを渡してください`));
38
+ }
39
+ for (const [key, schema] of Object.entries(schemas)) {
40
+ if (key !== "params" && key !== "query" && key !== "body") {
41
+ throw senriError("schema_key", new TypeError(`${path}: スキーマに渡せるのは params / query / body だけです("${key}" は使えません)`));
42
+ }
43
+ if (schema !== undefined && !isSchema(schema)) {
44
+ throw senriError("schema_type", new TypeError(`${path}: ${key} が Standard Schema のスキーマではありません(Zod・Valibot・ArkType などのスキーマを渡してください)`));
45
+ }
46
+ }
47
+ }
@@ -1,5 +1,4 @@
1
1
  import type { Context, HandlerResult } from "./context.ts";
2
- import type { Route } from "./router.ts";
3
2
  export declare class HttpError extends Error {
4
3
  status: number;
5
4
  headers: Headers;
@@ -17,7 +16,10 @@ export declare function conversionOriginOf(err: unknown): ConversionOrigin | nul
17
16
  export type ErrorHandler<V extends object = any> = (err: unknown, c: Context<V>) => HandlerResult | Promise<HandlerResult>;
18
17
  export type NotFoundHandler<V extends object = any> = (c: Context<V>) => HandlerResult | Promise<HandlerResult>;
19
18
  export declare function handleError(err: unknown, c: Context, onError: ErrorHandler): Promise<Response>;
20
- export declare function defaultOnError(dev: boolean): ErrorHandler;
21
- export declare function methodNotAllowed(c: Context, path: string, methods: string, dev: boolean): HttpError;
22
- export declare function checkQueryRequest(c: Context, dev: boolean): Promise<HttpError | null>;
23
- export declare function defaultNotFound(routes: Route[], dev: boolean): NotFoundHandler;
19
+ export declare function senriError<E extends Error>(code: string, error: E): E;
20
+ export declare function senriError(code: string, message: string): Error;
21
+ export declare function errorCode(err: unknown): string | undefined;
22
+ export declare const defaultOnError: ErrorHandler;
23
+ export declare const defaultNotFound: NotFoundHandler;
24
+ export declare function methodNotAllowed(methods: string): HttpError;
25
+ export declare function checkQueryRequest(c: Context): Promise<HttpError | null>;