@senrijs/senri 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +377 -2
- package/dist/adapters/node/index.d.ts +14 -0
- package/dist/adapters/node/index.js +152 -0
- package/dist/adapters/serve/index.d.ts +28 -0
- package/dist/adapters/serve/index.js +137 -0
- package/dist/adapters/static/index.d.ts +9 -0
- package/dist/adapters/static/index.js +120 -0
- package/dist/src/app.d.ts +86 -0
- package/dist/src/app.js +325 -0
- package/dist/src/body.d.ts +3 -0
- package/dist/src/body.js +45 -0
- package/dist/src/compose.d.ts +22 -0
- package/dist/src/compose.js +80 -0
- package/dist/src/connection.d.ts +3 -0
- package/dist/src/connection.js +49 -0
- package/dist/src/context.d.ts +58 -0
- package/dist/src/context.js +235 -0
- package/dist/src/cookie.d.ts +18 -0
- package/dist/src/cookie.js +73 -0
- package/dist/src/dev/dashboard/access.d.ts +6 -0
- package/dist/src/dev/dashboard/access.js +46 -0
- package/dist/src/dev/dashboard/index.d.ts +27 -0
- package/dist/src/dev/dashboard/index.js +51 -0
- package/dist/src/dev/dashboard/links.d.ts +3 -0
- package/dist/src/dev/dashboard/links.js +8 -0
- package/dist/src/dev/dashboard/log.d.ts +20 -0
- package/dist/src/dev/dashboard/log.js +53 -0
- package/dist/src/dev/dashboard/mask.d.ts +4 -0
- package/dist/src/dev/dashboard/mask.js +43 -0
- package/dist/src/dev/dashboard/record.d.ts +37 -0
- package/dist/src/dev/dashboard/record.js +50 -0
- package/dist/src/dev/dashboard/routes.d.ts +7 -0
- package/dist/src/dev/dashboard/routes.js +121 -0
- package/dist/src/dev/dashboard/sse.d.ts +7 -0
- package/dist/src/dev/dashboard/sse.js +73 -0
- package/dist/src/dev/editor-url.d.ts +14 -0
- package/dist/src/dev/editor-url.js +61 -0
- package/dist/src/dev/editor.d.ts +6 -0
- package/dist/src/dev/editor.js +14 -0
- package/dist/src/dev/error-page.d.ts +3 -0
- package/dist/src/dev/error-page.js +69 -0
- package/dist/src/dev/explain.d.ts +2 -0
- package/dist/src/dev/explain.js +37 -0
- package/dist/src/dev/fallback.d.ts +3 -0
- package/dist/src/dev/fallback.js +40 -0
- package/dist/src/dev/hints.d.ts +6 -0
- package/dist/src/dev/hints.js +85 -0
- package/dist/src/dev/html.d.ts +1 -0
- package/dist/src/dev/html.js +14 -0
- package/dist/src/dev/index.d.ts +3 -0
- package/dist/src/dev/index.js +185 -0
- package/dist/src/dev/messages.d.ts +2 -0
- package/dist/src/dev/messages.js +6 -0
- package/dist/src/dev/not-found-page.d.ts +7 -0
- package/dist/src/dev/not-found-page.js +33 -0
- package/dist/src/dev/not-found.d.ts +3 -0
- package/dist/src/dev/not-found.js +15 -0
- package/dist/src/dev/page-data.d.ts +95 -0
- package/dist/src/dev/page-data.js +0 -0
- package/dist/src/dev/page.generated.d.ts +1 -0
- package/dist/src/dev/page.generated.js +3 -0
- package/dist/src/dev/pages.d.ts +4 -0
- package/dist/src/dev/pages.js +59 -0
- package/dist/src/dev/registry.d.ts +3 -0
- package/dist/src/dev/registry.js +7 -0
- package/dist/src/dev/render.d.ts +5 -0
- package/dist/src/dev/render.js +42 -0
- package/dist/src/dev/server-timing.d.ts +6 -0
- package/dist/src/dev/server-timing.js +45 -0
- package/dist/src/dev/source.d.ts +20 -0
- package/dist/src/dev/source.js +106 -0
- package/dist/src/dev/stack.d.ts +12 -0
- package/dist/src/dev/stack.js +93 -0
- package/dist/src/dev/suggest.d.ts +12 -0
- package/dist/src/dev/suggest.js +95 -0
- package/dist/src/dev/validation.d.ts +3 -0
- package/dist/src/dev/validation.js +47 -0
- package/dist/src/errors.d.ts +25 -0
- package/dist/src/errors.js +97 -0
- package/dist/src/group.d.ts +37 -0
- package/dist/src/group.js +75 -0
- package/dist/src/health.d.ts +9 -0
- package/dist/src/health.js +40 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +12 -0
- package/dist/src/ip.d.ts +2 -0
- package/dist/src/ip.js +12 -0
- package/dist/src/logger.d.ts +18 -0
- package/dist/src/logger.js +110 -0
- package/dist/src/matcher.d.ts +16 -0
- package/dist/src/matcher.js +86 -0
- package/dist/src/middleware/body-limit.d.ts +6 -0
- package/dist/src/middleware/body-limit.js +47 -0
- package/dist/src/middleware/cors.d.ts +10 -0
- package/dist/src/middleware/cors.js +79 -0
- package/dist/src/middleware/response.d.ts +2 -0
- package/dist/src/middleware/response.js +28 -0
- package/dist/src/middleware/secure-headers.d.ts +19 -0
- package/dist/src/middleware/secure-headers.js +46 -0
- package/dist/src/router.d.ts +30 -0
- package/dist/src/router.js +177 -0
- package/dist/src/runtime.d.ts +5 -0
- package/dist/src/runtime.js +55 -0
- package/dist/src/validate.d.ts +46 -0
- package/dist/src/validate.js +55 -0
- package/package.json +90 -4
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context, HandlerResult } from "./context.ts";
|
|
2
|
+
export declare class HttpError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
headers: Headers;
|
|
5
|
+
constructor(status: number, message?: string, options?: {
|
|
6
|
+
headers?: HeadersInit;
|
|
7
|
+
cause?: unknown;
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export type ConversionOrigin = {
|
|
11
|
+
kind: "handler" | "middleware";
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function markConversionError(err: unknown, origin: ConversionOrigin): unknown;
|
|
15
|
+
export declare function conversionOriginOf(err: unknown): ConversionOrigin | null;
|
|
16
|
+
export type ErrorHandler<V extends object = any> = (err: unknown, c: Context<V>) => HandlerResult | Promise<HandlerResult>;
|
|
17
|
+
export type NotFoundHandler<V extends object = any> = (c: Context<V>) => HandlerResult | Promise<HandlerResult>;
|
|
18
|
+
export declare function handleError(err: unknown, c: Context, onError: ErrorHandler): Promise<Response>;
|
|
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>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// status 付きで投げられるエラー。message はそのままクライアントに見せる前提のもの
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
headers;
|
|
5
|
+
constructor(status, message, options = {}) {
|
|
6
|
+
super(message ?? `HTTP ${status}`, { cause: options.cause });
|
|
7
|
+
this.name = "HttpError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.headers = new Headers(options.headers);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const conversionOrigins = new WeakMap();
|
|
13
|
+
export function markConversionError(err, origin) {
|
|
14
|
+
if (typeof err === "object" && err !== null)
|
|
15
|
+
conversionOrigins.set(err, origin);
|
|
16
|
+
return err;
|
|
17
|
+
}
|
|
18
|
+
export function conversionOriginOf(err) {
|
|
19
|
+
return typeof err === "object" && err !== null ? (conversionOrigins.get(err) ?? null) : null;
|
|
20
|
+
}
|
|
21
|
+
// onError を通して、例外をレスポンスに変える
|
|
22
|
+
// HttpError なら status と headers を先に c に入れておくので、onError が文字列を返すだけでも正しい status になる
|
|
23
|
+
export async function handleError(err, c, onError) {
|
|
24
|
+
if (err instanceof HttpError) {
|
|
25
|
+
c.status(err.status);
|
|
26
|
+
err.headers.forEach((value, key) => c.header(key, value));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
c.status(500);
|
|
30
|
+
}
|
|
31
|
+
// ログに出すべき「本当のエラー」だけ c.error に入れる(404 や 403 はふつうの応答)
|
|
32
|
+
if (!(err instanceof HttpError) || err.status >= 500) {
|
|
33
|
+
c.error = err;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return c.toResponse(await onError(err, c));
|
|
37
|
+
}
|
|
38
|
+
catch (onErrorFailed) {
|
|
39
|
+
// onError 自体が壊れていたら、もう頼れるものがないので固定の 500 を返す
|
|
40
|
+
c.error = onErrorFailed;
|
|
41
|
+
return new Response("500 Internal Server Error", { status: 500 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function senriError(code, error) {
|
|
45
|
+
return Object.assign(typeof error === "string" ? new Error(error) : error, { code });
|
|
46
|
+
}
|
|
47
|
+
// Senri が投げたエラーの code(なければ undefined)
|
|
48
|
+
export function errorCode(err) {
|
|
49
|
+
const code = err?.code;
|
|
50
|
+
return typeof code === "string" ? code : undefined;
|
|
51
|
+
}
|
|
52
|
+
// 本番の既定。エラーの中身(DB のパスワードなど)がもれないよう、固定の文言だけ返す
|
|
53
|
+
// (開発モードの詳しい文言とエラーページは @senrijs/senri/dev が差し替える)
|
|
54
|
+
export const defaultOnError = (err, c) => c.text(err instanceof HttpError ? err.message : "500 Internal Server Error");
|
|
55
|
+
export const defaultNotFound = (c) => c.text("404 Not Found");
|
|
56
|
+
export function methodNotAllowed(methods) {
|
|
57
|
+
return senriError("method_not_allowed", new HttpError(405, "405 Method Not Allowed", { headers: { allow: methods } }));
|
|
58
|
+
}
|
|
59
|
+
// QUERY(RFC 10008)は、検索の条件をボディに入れて送るメソッド。ボディと、その形式(Content-Type)が必須
|
|
60
|
+
// ハンドラーを呼ぶ前に確かめる。どちらも問題なければ null
|
|
61
|
+
export async function checkQueryRequest(c) {
|
|
62
|
+
if (await isEmptyBody(c))
|
|
63
|
+
return senriError("query_no_body", new HttpError(400, "400 Bad Request"));
|
|
64
|
+
if (!c.req.headers.get("content-type")) {
|
|
65
|
+
return senriError("query_no_type", new HttpError(415, "415 Unsupported Media Type"));
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// ボディが空か。Content-Length があればそれで決める
|
|
70
|
+
// ないとき(Deno はボディがなくても空のストリームを渡す。チャンク転送のこともある)は、
|
|
71
|
+
// 複製(clone)のほうを中身のあるチャンクが来るまで読んで確かめる。元の c.req はそのまま読める
|
|
72
|
+
async function isEmptyBody(c) {
|
|
73
|
+
const req = c.req;
|
|
74
|
+
if (req.body === null)
|
|
75
|
+
return true;
|
|
76
|
+
const length = req.headers.get("content-length");
|
|
77
|
+
if (length !== null)
|
|
78
|
+
return length.trim() === "0";
|
|
79
|
+
// ミドルウェアが読み済みなら clone() は例外になる。読めたということは、ボディはあった
|
|
80
|
+
if (req.bodyUsed)
|
|
81
|
+
return false;
|
|
82
|
+
const reader = req.clone().body.getReader();
|
|
83
|
+
try {
|
|
84
|
+
// 長さ 0 のチャンクが来ることもある(Deno)
|
|
85
|
+
for (;;) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done)
|
|
88
|
+
return true;
|
|
89
|
+
if (value.byteLength > 0)
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
// 複製のほうはもう読まない(元のほうに、読んだ分ため込まれ続けないようにする)
|
|
95
|
+
reader.cancel().catch(() => { });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
type GroupParams<Prefix extends string, Path extends string> = Simplify<ExtractParams<`${Prefix}/${Path}`>>;
|
|
5
|
+
type GroupMethod<Prefix extends string, V extends object> = {
|
|
6
|
+
<Path extends string>(path: Path, handler: Handler<GroupParams<Prefix, Path>, V>): void;
|
|
7
|
+
<Path extends string, S extends RouteSchemas>(path: Path, schemas: S, handler: Handler<ValidatedParams<S, GroupParams<Prefix, Path>>, V, Validated<S>>): void;
|
|
8
|
+
};
|
|
9
|
+
export type Router<Prefix extends string = string, V extends object = any> = {
|
|
10
|
+
readonly prefix: Prefix;
|
|
11
|
+
use(pathOrMw: string | Middleware<V>, mw?: Middleware<V>): void;
|
|
12
|
+
get: GroupMethod<Prefix, V>;
|
|
13
|
+
post: GroupMethod<Prefix, V>;
|
|
14
|
+
put: GroupMethod<Prefix, V>;
|
|
15
|
+
patch: GroupMethod<Prefix, V>;
|
|
16
|
+
delete: GroupMethod<Prefix, V>;
|
|
17
|
+
all: GroupMethod<Prefix, V>;
|
|
18
|
+
head: GroupMethod<Prefix, V>;
|
|
19
|
+
options: GroupMethod<Prefix, V>;
|
|
20
|
+
on<Path extends string>(method: string | readonly string[], path: Path, handler: Handler<GroupParams<Prefix, Path>, V>): void;
|
|
21
|
+
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;
|
|
22
|
+
route(router: Router<string, V>): void;
|
|
23
|
+
};
|
|
24
|
+
export type GroupEntry = {
|
|
25
|
+
kind: "route";
|
|
26
|
+
method: string;
|
|
27
|
+
path: string;
|
|
28
|
+
handler: Handler;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "use";
|
|
31
|
+
path: string;
|
|
32
|
+
mw: Middleware;
|
|
33
|
+
};
|
|
34
|
+
export declare function joinPath(...parts: string[]): string;
|
|
35
|
+
export declare function groupEntries(router: Router): GroupEntry[];
|
|
36
|
+
export declare function createRouter<Prefix extends string, V extends object = Record<string, unknown>>(prefix: Prefix): Router<Prefix, V>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { normalizeMethod, splitPattern } from "./router.js";
|
|
2
|
+
import { routeHandler } from "./validate.js";
|
|
3
|
+
import { senriError } from "./errors.js";
|
|
4
|
+
const entries = new WeakMap();
|
|
5
|
+
export function joinPath(...parts) {
|
|
6
|
+
return "/" + parts.flatMap(splitPattern).join("/");
|
|
7
|
+
}
|
|
8
|
+
// ルーターに登録されたものを、プレフィックスを付けたパスで返す
|
|
9
|
+
export function groupEntries(router) {
|
|
10
|
+
const list = entries.get(router);
|
|
11
|
+
if (!list)
|
|
12
|
+
throw senriError("not_router", new TypeError("Not a router from createRouter()"));
|
|
13
|
+
return list("");
|
|
14
|
+
}
|
|
15
|
+
// V は c.set / c.get で使う変数の型。アプリと同じものを指定する: createRouter<"/admin", Vars>("/admin")
|
|
16
|
+
export function createRouter(prefix) {
|
|
17
|
+
// 子のルーターは、つなげた時点ではなく app.route の時点で中身を読む(あとから足したルートも入る)
|
|
18
|
+
const items = [];
|
|
19
|
+
const push = (name, path, handler) => {
|
|
20
|
+
items.push({ kind: "entry", entry: { kind: "route", method: name, path, handler } });
|
|
21
|
+
};
|
|
22
|
+
const method = (name) => ((path, a, b) => push(name, path, routeHandler(path, a, b)));
|
|
23
|
+
const router = {
|
|
24
|
+
prefix,
|
|
25
|
+
use(pathOrMw, mw) {
|
|
26
|
+
const path = typeof pathOrMw === "string" ? pathOrMw : "/";
|
|
27
|
+
const fn = typeof pathOrMw === "string" ? mw : pathOrMw;
|
|
28
|
+
if (!fn)
|
|
29
|
+
throw senriError("no_middleware", `Missing middleware: ${path}`);
|
|
30
|
+
items.push({ kind: "entry", entry: { kind: "use", path, mw: fn } });
|
|
31
|
+
},
|
|
32
|
+
get: method("GET"),
|
|
33
|
+
post: method("POST"),
|
|
34
|
+
put: method("PUT"),
|
|
35
|
+
patch: method("PATCH"),
|
|
36
|
+
delete: method("DELETE"),
|
|
37
|
+
all: method("ALL"),
|
|
38
|
+
head: method("HEAD"),
|
|
39
|
+
options: method("OPTIONS"),
|
|
40
|
+
on: ((methods, path, a, b) => {
|
|
41
|
+
const list = typeof methods === "string" ? [methods] : methods;
|
|
42
|
+
if (list.length === 0)
|
|
43
|
+
throw senriError("no_method", `Missing method: ${path}`);
|
|
44
|
+
const handler = routeHandler(path, a, b);
|
|
45
|
+
for (const m of list) {
|
|
46
|
+
if (m.toUpperCase() === "ALL")
|
|
47
|
+
throw senriError("on_all", "Use all() for every method");
|
|
48
|
+
// 名前の確認(CONNECT などの拒否)は、ここで先にしておく(app.route まで待たない)
|
|
49
|
+
push(normalizeMethod(m), path, handler);
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
route(child) {
|
|
53
|
+
if (!entries.has(child))
|
|
54
|
+
throw senriError("not_router", new TypeError("Not a router from createRouter()"));
|
|
55
|
+
items.push({ kind: "router", router: child });
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
entries.set(router, (base) => {
|
|
60
|
+
// ルーターが自分自身を(間接的にでも)含んでいると終わらないので止める
|
|
61
|
+
if (seen.has(router))
|
|
62
|
+
throw senriError("router_cycle", `Router ${prefix} contains itself`);
|
|
63
|
+
seen.add(router);
|
|
64
|
+
try {
|
|
65
|
+
const here = joinPath(base, prefix);
|
|
66
|
+
return items.flatMap((item) => item.kind === "router"
|
|
67
|
+
? entries.get(item.router)(here)
|
|
68
|
+
: [{ ...item.entry, path: joinPath(here, item.entry.path) }]);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
seen.delete(router);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return router;
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Middleware } from "./context.ts";
|
|
2
|
+
export type HealthOptions = {
|
|
3
|
+
path?: string | false;
|
|
4
|
+
readyPath?: string | false;
|
|
5
|
+
ready?: () => boolean | Promise<boolean>;
|
|
6
|
+
};
|
|
7
|
+
export declare function setDraining(value: boolean): void;
|
|
8
|
+
export declare function isDraining(): boolean;
|
|
9
|
+
export declare function health(options?: HealthOptions): Middleware;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// serve() が止まり始めた(SIGTERM などを受けた)か。プロセス全体で1つ
|
|
2
|
+
let draining = false;
|
|
3
|
+
export function setDraining(value) {
|
|
4
|
+
draining = value;
|
|
5
|
+
}
|
|
6
|
+
export function isDraining() {
|
|
7
|
+
return draining;
|
|
8
|
+
}
|
|
9
|
+
const plain = (body, status) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
10
|
+
export function health(options = {}) {
|
|
11
|
+
const live = options.path === undefined ? "/healthz" : options.path;
|
|
12
|
+
const ready = options.readyPath === undefined ? "/readyz" : options.readyPath;
|
|
13
|
+
return async function health(c, next) {
|
|
14
|
+
const method = c.req.method;
|
|
15
|
+
if (method !== "GET" && method !== "HEAD")
|
|
16
|
+
return next();
|
|
17
|
+
let path;
|
|
18
|
+
try {
|
|
19
|
+
path = c.path;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return next();
|
|
23
|
+
}
|
|
24
|
+
if (path === live)
|
|
25
|
+
return plain("ok", 200);
|
|
26
|
+
if (path !== ready)
|
|
27
|
+
return next();
|
|
28
|
+
if (draining)
|
|
29
|
+
return plain("shutting down", 503);
|
|
30
|
+
try {
|
|
31
|
+
if (options.ready && !(await options.ready()))
|
|
32
|
+
return plain("not ready", 503);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// 確かめる処理が失敗したなら、受けられるとは言えない(中身は外に出さない)
|
|
36
|
+
return plain("not ready", 503);
|
|
37
|
+
}
|
|
38
|
+
return plain("ready", 200);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { createApp, type App, type AppCore, type AppOptions, type DevHooks, type DevInstalled, type MountableApp, type Resolved, } from "./app.ts";
|
|
2
|
+
export { createRouter, type Router } from "./group.ts";
|
|
3
|
+
export { Context, type BindingsOf, type ExecutionContext, type Handler, type HandlerResult, type Middleware, type Next, type VariablesOf, } from "./context.ts";
|
|
4
|
+
export { HttpError, type ErrorHandler, type NotFoundHandler } from "./errors.ts";
|
|
5
|
+
export { ValidationError, type InferOutput, type RouteSchemas, type StandardIssue, type StandardSchemaV1, type ValidationTarget, } from "./validate.ts";
|
|
6
|
+
export type { CookieOptions } from "./cookie.ts";
|
|
7
|
+
export type { FormBody, FormValue } from "./body.ts";
|
|
8
|
+
export type { Layer, Timing, Trace } from "./compose.ts";
|
|
9
|
+
export { logger, showRoutes } from "./logger.ts";
|
|
10
|
+
export { health, type HealthOptions } from "./health.ts";
|
|
11
|
+
export { cors, type CorsOptions } from "./middleware/cors.ts";
|
|
12
|
+
export { secureHeaders, type SecureHeadersOptions } from "./middleware/secure-headers.ts";
|
|
13
|
+
export { bodyLimit, type BodyLimitOptions } from "./middleware/body-limit.ts";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @senrijs/senri の入口
|
|
2
|
+
// 開発モード(エラーページ・ダッシュボードなど)は @senrijs/senri/dev、Cookie は @senrijs/senri/cookie、接続元の IP は @senrijs/senri/ip
|
|
3
|
+
export { createApp, } from "./app.js";
|
|
4
|
+
export { createRouter } from "./group.js";
|
|
5
|
+
export { Context, } from "./context.js";
|
|
6
|
+
export { HttpError } from "./errors.js";
|
|
7
|
+
export { ValidationError, } from "./validate.js";
|
|
8
|
+
export { logger, showRoutes } from "./logger.js";
|
|
9
|
+
export { health } from "./health.js";
|
|
10
|
+
export { cors } from "./middleware/cors.js";
|
|
11
|
+
export { secureHeaders } from "./middleware/secure-headers.js";
|
|
12
|
+
export { bodyLimit } from "./middleware/body-limit.js";
|
package/dist/src/ip.d.ts
ADDED
package/dist/src/ip.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @senrijs/senri/ip の入口
|
|
2
|
+
// Context のメソッドにしないのは、使わないアプリのバンドルに入れないため(0.1 の c.ip)
|
|
3
|
+
import { connectionOf } from "./context.js";
|
|
4
|
+
import { clientIp } from "./connection.js";
|
|
5
|
+
// 接続元の IP アドレス。分からなければ null(app.fetch を直接呼んだときなど)
|
|
6
|
+
// - Bun / Deno / Node(senri/node): ランタイムが渡す接続元
|
|
7
|
+
// - Cloudflare Workers: Cloudflare が付ける CF-Connecting-IP
|
|
8
|
+
// X-Forwarded-For などのヘッダーは偽装できるので読まない。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読む
|
|
9
|
+
export function getIp(c) {
|
|
10
|
+
const { req, info } = connectionOf(c);
|
|
11
|
+
return clientIp(req, info);
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Middleware } from "./context.ts";
|
|
2
|
+
type LoggerOptions = {
|
|
3
|
+
format?: "pretty" | "json";
|
|
4
|
+
color?: boolean;
|
|
5
|
+
slow?: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function logger(options?: LoggerOptions): Middleware;
|
|
8
|
+
type RouteInfo = {
|
|
9
|
+
method: string;
|
|
10
|
+
path: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function showRoutes(app: {
|
|
13
|
+
routeList(): RouteInfo[];
|
|
14
|
+
}, url: string, options?: {
|
|
15
|
+
color?: boolean;
|
|
16
|
+
note?: string;
|
|
17
|
+
}): void;
|
|
18
|
+
export {};
|