@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
@@ -0,0 +1,45 @@
1
+ // desc は quoted-string なので、" と \ をエスケープする
2
+ // ヘッダーの値は ByteString(U+00FF まで)なので、ASCII の表示文字以外(日本語の名前など)は %XX にする
3
+ // (そのまま入れると Headers.set が例外を投げて、開発モードのレスポンスが返らなくなる)
4
+ function quote(text) {
5
+ const ascii = text.replace(/[^\x20-\x7e]/gu, (ch) => encodeURIComponent(ch));
6
+ return `"${ascii.replace(/[\\"]/g, (ch) => `\\${ch}`)}"`;
7
+ }
8
+ const ms = (n) => Math.max(0, n).toFixed(2);
9
+ // timings: 外側から順(最後がいちばん内側)。inner: いちばん内側の層の名前("GET /users/:id" や "notFound")
10
+ // 名前(metric-name)は、アプリが自分で付ける名前とぶつからないよう senri- で始める
11
+ export function serverTimingHeader(timings, inner, total) {
12
+ const entries = [];
13
+ const last = timings.length - 1;
14
+ timings.forEach((t, i) => {
15
+ // 例外などで途中までしか通っていないと、穴が空くことがある
16
+ if (!t)
17
+ return;
18
+ if (i === last && t.name === inner.name) {
19
+ entries.push(`senri-${inner.name};desc=${quote(inner.desc)};dur=${ms(t.total)}`);
20
+ }
21
+ else {
22
+ entries.push(`senri-${i + 1};desc=${quote(`${i + 1}. ${t.name} (self ${ms(t.self)}ms)`)};dur=${ms(t.total)}`);
23
+ }
24
+ });
25
+ entries.push(`senri-total;desc="total";dur=${ms(total)}`);
26
+ return entries.join(", ");
27
+ }
28
+ // レスポンスに足す。アプリが付けた Server-Timing は残して、後ろにつなぐ
29
+ export function withServerTiming(res, value) {
30
+ // 101(WebSocket のアップグレード)などは作り直すと壊れるので触らない
31
+ if (res.status < 200)
32
+ return res;
33
+ const current = res.headers.get("server-timing");
34
+ const next = current ? `${current}, ${value}` : value;
35
+ try {
36
+ res.headers.set("server-timing", next);
37
+ return res;
38
+ }
39
+ catch {
40
+ // fetch() や Response.redirect() の Response はヘッダーを変更できないので、作り直す
41
+ const headers = new Headers(res.headers);
42
+ headers.set("server-timing", next);
43
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
44
+ }
45
+ }
@@ -0,0 +1,20 @@
1
+ import type { StackFrame } from "./stack.ts";
2
+ export type CodeLine = {
3
+ num: number;
4
+ code: string;
5
+ hit: boolean;
6
+ };
7
+ export type CodeFrame = {
8
+ file: string;
9
+ line: number;
10
+ col: number;
11
+ lines: CodeLine[];
12
+ caret: string;
13
+ label: string;
14
+ };
15
+ export type ReadOptions = {
16
+ root?: string | null;
17
+ canRead?: (path: string) => boolean;
18
+ maxBytes?: number;
19
+ };
20
+ export declare function readCodeFrame(frame: StackFrame, options?: ReadOptions): Promise<CodeFrame | null>;
@@ -0,0 +1,106 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import * as runtime from "../runtime.js";
10
+ const CONTEXT = 3;
11
+ const MAX_BYTES = 1024 * 1024;
12
+ const NODE_FS = "node:fs/promises";
13
+ // 等幅フォントで全角(日本語など)は2文字分の幅になるので、^ の位置がずれないよう幅で数える
14
+ function width(text) {
15
+ let w = 0;
16
+ for (const ch of text) {
17
+ const cp = ch.codePointAt(0);
18
+ const wide = (cp >= 0x1100 && cp <= 0x115f) ||
19
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
20
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
21
+ (cp >= 0xf900 && cp <= 0xfaff) ||
22
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
23
+ (cp >= 0xff00 && cp <= 0xff60) ||
24
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
25
+ cp >= 0x1f300;
26
+ w += wide ? 2 : 1;
27
+ }
28
+ return w;
29
+ }
30
+ function makeCaret(code, col) {
31
+ const start = Math.min(Math.max(col - 1, 0), code.length);
32
+ // 字下げのタブはそのままタブにしておくと、上の行とそろう
33
+ const before = code.slice(0, start).replace(/[^\t]/g, (ch) => " ".repeat(width(ch)));
34
+ const target = code.slice(start).trimEnd();
35
+ return before + "^".repeat(Math.max(width(target), 1));
36
+ }
37
+ // "a/./b/../c" → "a/c"。../ でルートの外に出るパスを見抜くため
38
+ function normalize(path) {
39
+ const out = [];
40
+ for (const part of path.split("/")) {
41
+ if (part === ".")
42
+ continue;
43
+ if (part === "..")
44
+ out.pop();
45
+ else
46
+ out.push(part);
47
+ }
48
+ return out.join("/");
49
+ }
50
+ function isInside(path, root) {
51
+ const fold = (p) => (/^[A-Za-z]:\//.test(p) ? p.toLowerCase() : p);
52
+ const p = fold(normalize(path));
53
+ const r = fold(normalize(root)).replace(/\/$/, "");
54
+ return p.startsWith(`${r}/`);
55
+ }
56
+ export async function readCodeFrame(frame, options = {}) {
57
+ // 利用者のコードだけ。Senri 内部・node_modules・ランタイム内部は読まない
58
+ if (frame.kind !== "user" || frame.line < 1)
59
+ return null;
60
+ const root = options.root === undefined ? runtime.cwd() : options.root;
61
+ if (!root)
62
+ return null;
63
+ // スタックに出てきたパスでも、プロジェクトの外や node_modules の中は読まない
64
+ if (!isInside(frame.file, root) || normalize(frame.file).includes("/node_modules/"))
65
+ return null;
66
+ const canRead = options.canRead ?? runtime.canRead;
67
+ if (!canRead(frame.file))
68
+ return null;
69
+ let text;
70
+ try {
71
+ // 動的 import にしておくと、ファイルを読めない環境でも Senri 自体は読み込める
72
+ const fs = (await import(__rewriteRelativeImportExtension(NODE_FS)));
73
+ const stat = await fs.stat(frame.file);
74
+ if (stat.size > (options.maxBytes ?? MAX_BYTES))
75
+ return null;
76
+ text = await fs.readFile(frame.file, "utf8");
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const all = text.split(/\r?\n/);
82
+ // 最後の改行のあとの空文字は行として数えない
83
+ if (all.at(-1) === "")
84
+ all.pop();
85
+ if (frame.line > all.length)
86
+ return null;
87
+ const first = Math.max(1, frame.line - CONTEXT);
88
+ const last = Math.min(all.length, frame.line + CONTEXT);
89
+ const lines = [];
90
+ for (let num = first; num <= last; num++) {
91
+ lines.push({ num, code: all[num - 1], hit: num === frame.line });
92
+ }
93
+ const hitLine = all[frame.line - 1];
94
+ // エラーの位置の直前が "throw" か "throw new" なら、その throw で投げたエラー
95
+ // (V8 は "new" の位置、Bun は "Error" の位置を指すので、どちらでも当たるようにする)
96
+ // 行のどこかに throw があるだけでは決めない(同じ行の別の場所で起きたエラーもあるので)
97
+ const thrownHere = /\bthrow\s+(?:new\s+)?$/.test(hitLine.slice(0, Math.max(frame.col - 1, 0)));
98
+ return {
99
+ file: frame.file,
100
+ line: frame.line,
101
+ col: frame.col,
102
+ lines,
103
+ caret: makeCaret(hitLine, frame.col),
104
+ label: thrownHere ? "ここで throw" : "ここでエラー",
105
+ };
106
+ }
@@ -0,0 +1,12 @@
1
+ export type FrameKind = "user" | "senri" | "node_modules" | "runtime";
2
+ export type StackFrame = {
3
+ fn: string;
4
+ file: string;
5
+ line: number;
6
+ col: number;
7
+ kind: FrameKind;
8
+ };
9
+ export type ParseOptions = {
10
+ senriDirs?: string[];
11
+ };
12
+ export declare function parseStack(stack: string, options?: ParseOptions): StackFrame[];
@@ -0,0 +1,93 @@
1
+ // err.stack(文字列)を、ページで扱いやすい { fn, file, line, col } の配列にする
2
+ //
3
+ // 3ランタイムとも " at 関数名 (場所)" か " at 場所" という V8 風の書式だが、「場所」の書き方が違う
4
+ // - Bun: C:\Users\me\app.ts:6:15 (Windows の生のパス)
5
+ // - Node: file:///C:/Users/SHO%20T/app.ts:6:11 (エンコードされた file:// URL)
6
+ // - Deno: file:///C:/Users/SHO T/app.ts:6:11 (エンコードされていない file:// URL も混ざる)
7
+ // そこで、場所を「末尾の :行:列」から逆に読み、パスはスラッシュ区切りの絶対パスにそろえる
8
+ // このファイルは src/dev/stack.ts(ビルド後は dist/src/dev/stack.js)にある
9
+ // そこから2つ上がパッケージのルートで、その下の src / adapters / dist が Senri 本体
10
+ // Cloudflare Workers のように1つのファイルにまとめられていると import.meta.url がない(またはファイルの URL でない)。
11
+ // そのときは Senri の場所が分からないので、区別しない(どのフレームも利用者のコードとして扱う)
12
+ function defaultSenriDirs() {
13
+ const here = import.meta.url;
14
+ // data: URL のように、相対パスを解決できない URL のこともある
15
+ if (typeof here !== "string" || !URL.canParse("../..", here))
16
+ return [];
17
+ const root = toPath(new URL("../..", here).href).replace(/\/$/, "");
18
+ // ビルド後は root が .../dist になっている
19
+ const pkg = root.endsWith("/dist") ? root.slice(0, -"/dist".length) : root;
20
+ return [`${pkg}/src`, `${pkg}/adapters`, `${pkg}/dist`];
21
+ }
22
+ // "file:///C:/a%20b/x.ts" や "C:\a b\x.ts" を "C:/a b/x.ts" にする
23
+ function toPath(location) {
24
+ if (location.startsWith("file://")) {
25
+ // new URL は空白をエンコードし直すので、エンコードあり・なしのどちらでも同じ結果になる
26
+ const pathname = decodeURIComponent(new URL(location).pathname);
27
+ // Windows: "/C:/Users/..." の先頭の "/" を取る
28
+ return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
29
+ }
30
+ return location.replaceAll("\\", "/");
31
+ }
32
+ function isRuntime(location) {
33
+ return /^(node|ext|bun|deno):/.test(location) || location === "native" || !location.includes("/");
34
+ }
35
+ // Windows はドライブ文字やフォルダ名の大文字小文字を区別しないので、そろえて比べる
36
+ function sameOrInside(path, dir) {
37
+ const norm = (p) => (/^[A-Za-z]:\//.test(p) ? p.toLowerCase() : p);
38
+ const p = norm(path);
39
+ const d = norm(dir).replace(/\/$/, "");
40
+ return p === d || p.startsWith(`${d}/`);
41
+ }
42
+ function classify(file, senriDirs) {
43
+ if (isRuntime(file))
44
+ return "runtime";
45
+ // node_modules/senri は Senri 本体として扱いたいので、node_modules より先に見る
46
+ if (senriDirs.some((dir) => sameOrInside(file, dir)))
47
+ return "senri";
48
+ if (file.includes("/node_modules/"))
49
+ return "node_modules";
50
+ return "user";
51
+ }
52
+ // "Object.auth [as mw]" → "auth"、"async getUser" → "getUser"
53
+ function cleanName(fn) {
54
+ const name = fn
55
+ .replace(/^async /, "")
56
+ .replace(/ \[as [^\]]+\]$/, "")
57
+ .replace(/^Object\./, "");
58
+ return name === "" ? "<anonymous>" : name;
59
+ }
60
+ // "場所" を { file, line, col } に。末尾が :行:列 でなければ行も列も 0
61
+ function splitLocation(location) {
62
+ const m = /^(.*):(\d+):(\d+)$/.exec(location);
63
+ if (!m)
64
+ return { file: location, line: 0, col: 0 };
65
+ return { file: toPath(m[1]), line: Number(m[2]), col: Number(m[3]) };
66
+ }
67
+ function parseLine(line, senriDirs) {
68
+ const m = /^\s*at (.*)$/.exec(line);
69
+ if (!m)
70
+ return null;
71
+ const rest = m[1];
72
+ // "関数名 (場所)" の形。パスにも "(" が入りうる("Program Files (x86)")ので、
73
+ // 最初の " (" で切り、末尾が ")" で閉じているものだけを採用する
74
+ const open = rest.indexOf(" (");
75
+ let fn = "<anonymous>";
76
+ let location = rest;
77
+ if (open !== -1 && rest.endsWith(")")) {
78
+ fn = cleanName(rest.slice(0, open));
79
+ location = rest.slice(open + 2, -1);
80
+ }
81
+ const { file, line: lineNo, col } = splitLocation(location);
82
+ return { fn, file, line: lineNo, col, kind: classify(file, senriDirs) };
83
+ }
84
+ export function parseStack(stack, options = {}) {
85
+ const senriDirs = (options.senriDirs ?? defaultSenriDirs()).map((d) => d.replaceAll("\\", "/"));
86
+ const frames = [];
87
+ for (const line of stack.split("\n")) {
88
+ const frame = parseLine(line, senriDirs);
89
+ if (frame)
90
+ frames.push(frame);
91
+ }
92
+ return frames;
93
+ }
@@ -0,0 +1,12 @@
1
+ export declare function distance(a: string, b: string): number;
2
+ export type Difference = {
3
+ parts: {
4
+ text: string;
5
+ mark: boolean;
6
+ }[];
7
+ note: string;
8
+ };
9
+ export declare function explainDifference(requested: string, suggested: string): Difference;
10
+ export declare function suggest<R extends {
11
+ path: string;
12
+ }>(routes: R[], path: string): R | null;
@@ -0,0 +1,95 @@
1
+ // dp[i][j] = a の先頭 i 文字と b の先頭 j 文字が、何回の「追加・削除・置き換え」で同じになるか
2
+ function table(a, b) {
3
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
4
+ for (let i = 1; i <= a.length; i++) {
5
+ for (let j = 1; j <= b.length; j++) {
6
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
7
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
8
+ }
9
+ }
10
+ return dp;
11
+ }
12
+ // 2つの文字列が、何回の「追加・削除・置き換え」で同じになるかを数える
13
+ export function distance(a, b) {
14
+ return table(a, b)[a.length][b.length];
15
+ }
16
+ // リクエストされたパスと候補のパスの、どこが違うかを調べる
17
+ // DP の表を右下(両方の最後)から左上へたどると、1文字ずつ「同じ / 足りない / 多い / 違う」が分かる
18
+ export function explainDifference(requested, suggested) {
19
+ const dp = table(requested, suggested);
20
+ const ops = [];
21
+ let i = requested.length;
22
+ let j = suggested.length;
23
+ while (i > 0 || j > 0) {
24
+ const here = dp[i][j];
25
+ // 「足りない」を先に試すと、"/helo" → "/hello" で後ろの l に印が付く("/hel" + "l" + "o")
26
+ if (j > 0 && here === dp[i][j - 1] + 1) {
27
+ ops.push({ kind: "missing", ch: suggested[j - 1] });
28
+ j--;
29
+ }
30
+ else if (i > 0 && j > 0 && requested[i - 1] === suggested[j - 1] && here === dp[i - 1][j - 1]) {
31
+ ops.push({ kind: "same", ch: suggested[j - 1] });
32
+ i--;
33
+ j--;
34
+ }
35
+ else if (i > 0 && j > 0 && here === dp[i - 1][j - 1] + 1) {
36
+ ops.push({ kind: "changed", ch: suggested[j - 1], was: requested[i - 1] });
37
+ i--;
38
+ j--;
39
+ }
40
+ else {
41
+ ops.push({ kind: "extra", ch: requested[i - 1] });
42
+ i--;
43
+ }
44
+ }
45
+ ops.reverse();
46
+ // 候補のパスを、印あり・なしのかたまりに分ける(「多い」文字は候補にないので出てこない)
47
+ const parts = [];
48
+ for (const op of ops) {
49
+ if (op.kind === "extra")
50
+ continue;
51
+ const mark = op.kind !== "same";
52
+ const last = parts.at(-1);
53
+ if (last && last.mark === mark)
54
+ last.text += op.ch;
55
+ else
56
+ parts.push({ text: op.ch, mark });
57
+ }
58
+ const diffs = ops.filter((op) => op.kind !== "same");
59
+ const chars = diffs.map((op) => op.ch).join("");
60
+ let note;
61
+ if (diffs.every((op) => op.kind === "missing")) {
62
+ note = `"${chars}" が${diffs.length}文字足りません`;
63
+ }
64
+ else if (diffs.every((op) => op.kind === "extra")) {
65
+ note = `"${chars}" が${diffs.length}文字多いです`;
66
+ }
67
+ else if (diffs.length === 1 && diffs[0].kind === "changed") {
68
+ note = `"${diffs[0].ch}" のところが "${diffs[0].was}" になっています`;
69
+ }
70
+ else {
71
+ note = `${diffs.length}か所違います`;
72
+ }
73
+ return { parts, note };
74
+ }
75
+ const MAX_DISTANCE = 3;
76
+ // これより長いパスはタイプミスではないと見なして計算しない(長いほど計算が重くなるので)
77
+ const MAX_PATH_LENGTH = 200;
78
+ // いちばん近いルートを探す(距離が3より大きければ null)
79
+ export function suggest(routes, path) {
80
+ if (path.length > MAX_PATH_LENGTH)
81
+ return null;
82
+ let closest = null;
83
+ let best = Infinity;
84
+ for (const r of routes) {
85
+ // 長さの差がそのまま距離の下限になるので、差が大きければ計算するまでもない
86
+ if (Math.abs(r.path.length - path.length) > MAX_DISTANCE)
87
+ continue;
88
+ const d = distance(r.path, path);
89
+ if (d < best) {
90
+ closest = r;
91
+ best = d;
92
+ }
93
+ }
94
+ return closest && best <= MAX_DISTANCE ? closest : null;
95
+ }
@@ -0,0 +1,23 @@
1
+ import type { Context, HandlerResult } from "./context.ts";
2
+ import type { Route } from "./router.ts";
3
+ export declare class HttpError extends Error {
4
+ status: number;
5
+ headers: Headers;
6
+ constructor(status: number, message?: string, options?: {
7
+ headers?: HeadersInit;
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export type ConversionOrigin = {
12
+ kind: "handler" | "middleware";
13
+ name: string;
14
+ };
15
+ export declare function markConversionError(err: unknown, origin: ConversionOrigin): unknown;
16
+ export declare function conversionOriginOf(err: unknown): ConversionOrigin | null;
17
+ export type ErrorHandler<V extends object = any> = (err: unknown, c: Context<V>) => HandlerResult | Promise<HandlerResult>;
18
+ export type NotFoundHandler<V extends object = any> = (c: Context<V>) => HandlerResult | Promise<HandlerResult>;
19
+ 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;
@@ -0,0 +1,136 @@
1
+ import { suggest } from "./dev/suggest.js";
2
+ import { wantsHtml } from "./dev/render.js";
3
+ // status 付きで投げられるエラー。message はそのままクライアントに見せる前提のもの
4
+ export class HttpError extends Error {
5
+ status;
6
+ headers;
7
+ constructor(status, message, options = {}) {
8
+ super(message ?? `HTTP ${status}`, { cause: options.cause });
9
+ this.name = "HttpError";
10
+ this.status = status;
11
+ this.headers = new Headers(options.headers);
12
+ }
13
+ }
14
+ const conversionOrigins = new WeakMap();
15
+ export function markConversionError(err, origin) {
16
+ if (typeof err === "object" && err !== null)
17
+ conversionOrigins.set(err, origin);
18
+ return err;
19
+ }
20
+ export function conversionOriginOf(err) {
21
+ return typeof err === "object" && err !== null ? (conversionOrigins.get(err) ?? null) : null;
22
+ }
23
+ // onError を通して、例外をレスポンスに変える
24
+ // HttpError なら status と headers を先に c に入れておくので、onError が文字列を返すだけでも正しい status になる
25
+ export async function handleError(err, c, onError) {
26
+ if (err instanceof HttpError) {
27
+ c.status(err.status);
28
+ err.headers.forEach((value, key) => c.header(key, value));
29
+ }
30
+ else {
31
+ c.status(500);
32
+ }
33
+ // ログに出すべき「本当のエラー」だけ c.error に入れる(404 や 403 はふつうの応答)
34
+ if (!(err instanceof HttpError) || err.status >= 500) {
35
+ c.error = err;
36
+ }
37
+ try {
38
+ return c.toResponse(await onError(err, c));
39
+ }
40
+ catch (onErrorFailed) {
41
+ // onError 自体が壊れていたら、もう頼れるものがないので固定の 500 を返す
42
+ c.error = onErrorFailed;
43
+ return new Response("500 Internal Server Error", { status: 500 });
44
+ }
45
+ }
46
+ // 本番では、エラーの中身(DB のパスワードなど)や、ルートの一覧がもれないよう固定の文言だけ返す
47
+ // dev: true のときだけ、開発者向けの詳しい情報を足す
48
+ // validate.ts を import すると循環するので、形で見分ける
49
+ function isValidationError(err) {
50
+ return err.name === "ValidationError" && typeof err.describe === "function";
51
+ }
52
+ export function defaultOnError(dev) {
53
+ return (err, c) => {
54
+ if (err instanceof HttpError) {
55
+ // バリデーションのエラーは、開発モードでだけ、どこが違うかを返す
56
+ if (dev && isValidationError(err))
57
+ return c.text(err.describe());
58
+ return c.text(err.message);
59
+ }
60
+ if (!dev) {
61
+ return c.text("500 Internal Server Error");
62
+ }
63
+ return c.text(`500 Internal Server Error: ${err instanceof Error ? err.message : String(err)}`);
64
+ };
65
+ }
66
+ export function methodNotAllowed(c, path, methods, dev) {
67
+ const message = dev
68
+ ? `405 Method Not Allowed: ${c.req.method} ${path}\n${methods} ならあります`
69
+ : "405 Method Not Allowed";
70
+ return new HttpError(405, message, { headers: { allow: methods } });
71
+ }
72
+ // QUERY(RFC 10008)は、検索の条件をボディに入れて送るメソッド。ボディと、その形式(Content-Type)が必須
73
+ // ハンドラーを呼ぶ前に確かめる。どちらも問題なければ null
74
+ export async function checkQueryRequest(c, dev) {
75
+ if (await isEmptyBody(c)) {
76
+ return new HttpError(400, dev
77
+ ? "400 Bad Request: QUERY リクエストにボディがありません\nQUERY は、検索の条件をボディに入れて送るメソッドです"
78
+ : "400 Bad Request");
79
+ }
80
+ if (!c.req.headers.get("content-type")) {
81
+ return new HttpError(415, dev
82
+ ? "415 Unsupported Media Type: QUERY リクエストに Content-Type がありません\nボディの形式を指定してください(例: Content-Type: application/json)"
83
+ : "415 Unsupported Media Type");
84
+ }
85
+ return null;
86
+ }
87
+ // ボディが空か。Content-Length があればそれで決める
88
+ // ないとき(Deno はボディがなくても空のストリームを渡す。チャンク転送のこともある)は、
89
+ // 複製(clone)のほうを中身のあるチャンクが来るまで読んで確かめる。元の c.req はそのまま読める
90
+ async function isEmptyBody(c) {
91
+ const req = c.req;
92
+ if (req.body === null)
93
+ return true;
94
+ const length = req.headers.get("content-length");
95
+ if (length !== null)
96
+ return length.trim() === "0";
97
+ // ミドルウェアが読み済みなら clone() は例外になる。読めたということは、ボディはあった
98
+ if (req.bodyUsed)
99
+ return false;
100
+ const reader = req.clone().body.getReader();
101
+ try {
102
+ // 長さ 0 のチャンクが来ることもある(Deno)
103
+ for (;;) {
104
+ const { done, value } = await reader.read();
105
+ if (done)
106
+ return true;
107
+ if (value.byteLength > 0)
108
+ return false;
109
+ }
110
+ }
111
+ finally {
112
+ // 複製のほうはもう読まない(元のほうに、読んだ分ため込まれ続けないようにする)
113
+ reader.cancel().catch(() => { });
114
+ }
115
+ }
116
+ export function defaultNotFound(routes, dev) {
117
+ return async (c) => {
118
+ if (!dev) {
119
+ return c.text("404 Not Found");
120
+ }
121
+ // ブラウザで開いたときは、ルートの一覧付きの404ページ(HTML)にする
122
+ // ページの HTML は大きいので、本番では読み込まないよう、ここで初めて import() する
123
+ if (wantsHtml(c.req)) {
124
+ const { notFoundPage } = await import("./dev/not-found-page.js");
125
+ return notFoundPage(c, routes);
126
+ }
127
+ // ルートと同じく、デコード・正規化したパスで比べる(notFound に来るのはデコードできたリクエストだけ)
128
+ const path = c.path;
129
+ let message = `404 Not Found: ${c.req.method} ${path}`;
130
+ const closest = suggest(routes, path);
131
+ if (closest) {
132
+ message += `\nもしかして: ${closest.method} ${closest.path} ?`;
133
+ }
134
+ return c.text(message);
135
+ };
136
+ }
@@ -0,0 +1,38 @@
1
+ import type { Handler, Middleware } from "./context.ts";
2
+ import { type ExtractParams, type Simplify } from "./router.ts";
3
+ import { type RouteSchemas, type Validated, type ValidatedParams } from "./validate.ts";
4
+ export declare function routeHandler(path: string, a: Handler | RouteSchemas, b: Handler | undefined): Handler;
5
+ type GroupParams<Prefix extends string, Path extends string> = Simplify<ExtractParams<`${Prefix}/${Path}`>>;
6
+ type GroupMethod<Prefix extends string, V extends object> = {
7
+ <Path extends string>(path: Path, handler: Handler<GroupParams<Prefix, Path>, V>): void;
8
+ <Path extends string, S extends RouteSchemas>(path: Path, schemas: S, handler: Handler<ValidatedParams<S, GroupParams<Prefix, Path>>, V, Validated<S>>): void;
9
+ };
10
+ export type Router<Prefix extends string = string, V extends object = any> = {
11
+ readonly prefix: Prefix;
12
+ use(pathOrMw: string | Middleware<V>, mw?: Middleware<V>): void;
13
+ get: GroupMethod<Prefix, V>;
14
+ post: GroupMethod<Prefix, V>;
15
+ put: GroupMethod<Prefix, V>;
16
+ patch: GroupMethod<Prefix, V>;
17
+ delete: GroupMethod<Prefix, V>;
18
+ all: GroupMethod<Prefix, V>;
19
+ head: GroupMethod<Prefix, V>;
20
+ options: GroupMethod<Prefix, V>;
21
+ on<Path extends string>(method: string | readonly string[], path: Path, handler: Handler<GroupParams<Prefix, Path>, V>): void;
22
+ on<Path extends string, S extends RouteSchemas>(method: string | readonly string[], path: Path, schemas: S, handler: Handler<ValidatedParams<S, GroupParams<Prefix, Path>>, V, Validated<S>>): void;
23
+ route(router: Router<string, V>): void;
24
+ };
25
+ export type GroupEntry = {
26
+ kind: "route";
27
+ method: string;
28
+ path: string;
29
+ handler: Handler;
30
+ } | {
31
+ kind: "use";
32
+ path: string;
33
+ mw: Middleware;
34
+ };
35
+ export declare function joinPath(...parts: string[]): string;
36
+ export declare function groupEntries(router: Router): GroupEntry[];
37
+ export declare function createRouter<Prefix extends string, V extends object = Record<string, unknown>>(prefix: Prefix): Router<Prefix, V>;
38
+ export {};