@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,110 @@
|
|
|
1
|
+
const C = {
|
|
2
|
+
reset: "\x1b[0m",
|
|
3
|
+
dim: "\x1b[2m",
|
|
4
|
+
bold: "\x1b[1m",
|
|
5
|
+
red: "\x1b[31m",
|
|
6
|
+
green: "\x1b[32m",
|
|
7
|
+
yellow: "\x1b[33m",
|
|
8
|
+
blue: "\x1b[34m",
|
|
9
|
+
magenta: "\x1b[35m",
|
|
10
|
+
cyan: "\x1b[36m",
|
|
11
|
+
};
|
|
12
|
+
const METHOD_COLOR = {
|
|
13
|
+
GET: C.green,
|
|
14
|
+
POST: C.cyan,
|
|
15
|
+
PUT: C.yellow,
|
|
16
|
+
DELETE: C.red,
|
|
17
|
+
PATCH: C.magenta,
|
|
18
|
+
};
|
|
19
|
+
// ログには、届いたままのパスを出す(c.path はデコード済みで、不正なリクエストだと 400 を投げるため)
|
|
20
|
+
// Bun は不正な Host のとき req.url を相対 URL にするので、URL として読めなければそのまま出す
|
|
21
|
+
function rawPath(req) {
|
|
22
|
+
try {
|
|
23
|
+
return new URL(req.url).pathname;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return req.url;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function logger(options = {}) {
|
|
30
|
+
const format = options.format ?? "pretty";
|
|
31
|
+
const color = options.color ?? true;
|
|
32
|
+
const slow = options.slow ?? 500;
|
|
33
|
+
const paint = (code, text) => color ? `${code}${text}${C.reset}` : text;
|
|
34
|
+
function statusColor(status) {
|
|
35
|
+
if (status >= 500)
|
|
36
|
+
return C.red;
|
|
37
|
+
if (status >= 400)
|
|
38
|
+
return C.yellow;
|
|
39
|
+
if (status >= 300)
|
|
40
|
+
return C.cyan;
|
|
41
|
+
return C.green;
|
|
42
|
+
}
|
|
43
|
+
function pinoLevel(status) {
|
|
44
|
+
if (status >= 500)
|
|
45
|
+
return 50;
|
|
46
|
+
if (status >= 400)
|
|
47
|
+
return 40;
|
|
48
|
+
return 30;
|
|
49
|
+
}
|
|
50
|
+
// 名前付きの関数にしておくと、ダッシュボードなどで "logger" と表示できる
|
|
51
|
+
return async function logger(c, next) {
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
const res = await next();
|
|
54
|
+
const ms = Date.now() - start;
|
|
55
|
+
const method = c.req.method;
|
|
56
|
+
const path = rawPath(c.req);
|
|
57
|
+
// pino 形式のエラー(エラーがなければ undefined)
|
|
58
|
+
const err = c.error instanceof Error
|
|
59
|
+
? { type: c.error.name, message: c.error.message, stack: c.error.stack }
|
|
60
|
+
: c.error !== undefined
|
|
61
|
+
? { message: String(c.error) }
|
|
62
|
+
: undefined;
|
|
63
|
+
if (format === "json") {
|
|
64
|
+
console.log(JSON.stringify({
|
|
65
|
+
level: pinoLevel(res.status),
|
|
66
|
+
time: Date.now(),
|
|
67
|
+
req: { method, url: path },
|
|
68
|
+
res: { statusCode: res.status },
|
|
69
|
+
responseTime: ms,
|
|
70
|
+
err,
|
|
71
|
+
msg: err ? "request errored" : "request completed",
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
76
|
+
console.log([
|
|
77
|
+
paint(C.dim, time),
|
|
78
|
+
paint(METHOD_COLOR[method] ?? C.bold, method.padEnd(7)),
|
|
79
|
+
path.padEnd(24),
|
|
80
|
+
paint(statusColor(res.status), String(res.status)),
|
|
81
|
+
paint(ms > slow ? C.red : C.dim, `${ms}ms`.padStart(6)),
|
|
82
|
+
].join(" "));
|
|
83
|
+
// エラーがあったら、その下に赤で詳細を出す
|
|
84
|
+
if (err) {
|
|
85
|
+
console.log(paint(C.red, ` ${err.stack ?? err.message}`));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const ACCENT = "\x1b[38;2;227;65;43m";
|
|
90
|
+
export function showRoutes(app, url,
|
|
91
|
+
// note: URL の横に薄く出す補足(serve() は、ポートをどこから決めたかを出す)
|
|
92
|
+
options = {}) {
|
|
93
|
+
const color = options.color ?? true;
|
|
94
|
+
const paint = (code, text) => color ? `${code}${text}${C.reset}` : text;
|
|
95
|
+
// パスのアルファベット順 → 同じパスならメソッド順
|
|
96
|
+
const list = [...app.routeList()].sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
97
|
+
// ":id" みたいなパラメーター部分だけ色を付ける
|
|
98
|
+
const highlight = (path) => path
|
|
99
|
+
.split("/")
|
|
100
|
+
.map((seg) => (seg.startsWith(":") ? paint(C.cyan, seg) : seg))
|
|
101
|
+
.join("/");
|
|
102
|
+
console.log("");
|
|
103
|
+
const note = options.note ? ` ${paint(C.dim, `(${options.note})`)}` : "";
|
|
104
|
+
console.log(` ${paint(C.bold, "senr")}${paint(ACCENT, "/")} ${paint(C.green, "ready")} ${url}${note}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
for (const r of list) {
|
|
107
|
+
console.log(` ${paint(METHOD_COLOR[r.method] ?? C.bold, r.method.padEnd(7))} ${highlight(r.path)}`);
|
|
108
|
+
}
|
|
109
|
+
console.log("");
|
|
110
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Route } from "./router.ts";
|
|
2
|
+
export type Leaf = Route[];
|
|
3
|
+
export type Match = {
|
|
4
|
+
leaf: Leaf;
|
|
5
|
+
values: string[];
|
|
6
|
+
};
|
|
7
|
+
export type Matcher = {
|
|
8
|
+
add(route: Route): void;
|
|
9
|
+
staticLeaf(path: string): Leaf | undefined;
|
|
10
|
+
match(segments: readonly string[]): Match[];
|
|
11
|
+
first(segments: readonly string[], method: string): {
|
|
12
|
+
route: Route;
|
|
13
|
+
values: string[];
|
|
14
|
+
} | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function createMatcher(): Matcher;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const node = () => ({ statics: null, param: null, leaf: null });
|
|
2
|
+
const NO_VALUES = [];
|
|
3
|
+
export function createMatcher() {
|
|
4
|
+
const root = node();
|
|
5
|
+
// パラメーターのないルートだけ。キーは "/users/me" の形(c.path と同じ)
|
|
6
|
+
const statics = new Map();
|
|
7
|
+
const leafOf = (route) => {
|
|
8
|
+
let n = root;
|
|
9
|
+
for (const segment of route.segments) {
|
|
10
|
+
if (segment.startsWith(":")) {
|
|
11
|
+
n = n.param ??= node();
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
n.statics ??= new Map();
|
|
15
|
+
let child = n.statics.get(segment);
|
|
16
|
+
if (!child)
|
|
17
|
+
n.statics.set(segment, (child = node()));
|
|
18
|
+
n = child;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return (n.leaf ??= []);
|
|
22
|
+
};
|
|
23
|
+
const walk = (n, segments, i, values, out) => {
|
|
24
|
+
if (i === segments.length) {
|
|
25
|
+
if (n.leaf)
|
|
26
|
+
out.push({ leaf: n.leaf, values: values.length > 0 ? values.slice() : NO_VALUES });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const segment = segments[i];
|
|
30
|
+
const next = n.statics?.get(segment);
|
|
31
|
+
if (next)
|
|
32
|
+
walk(next, segments, i + 1, values, out);
|
|
33
|
+
if (n.param) {
|
|
34
|
+
values.push(segment);
|
|
35
|
+
walk(n.param, segments, i + 1, values, out);
|
|
36
|
+
values.pop();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
// walk と同じ順番でたどり、メソッドが合うルートを見つけたらそこで止める
|
|
40
|
+
const seek = (n, segments, i, method, values) => {
|
|
41
|
+
if (i === segments.length) {
|
|
42
|
+
if (n.leaf) {
|
|
43
|
+
for (const route of n.leaf)
|
|
44
|
+
if (route.method === method)
|
|
45
|
+
return route;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const segment = segments[i];
|
|
50
|
+
const next = n.statics?.get(segment);
|
|
51
|
+
if (next) {
|
|
52
|
+
const found = seek(next, segments, i + 1, method, values);
|
|
53
|
+
if (found)
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
if (n.param) {
|
|
57
|
+
values.push(segment);
|
|
58
|
+
const found = seek(n.param, segments, i + 1, method, values);
|
|
59
|
+
if (found)
|
|
60
|
+
return found;
|
|
61
|
+
values.pop();
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
add(route) {
|
|
67
|
+
const leaf = leafOf(route);
|
|
68
|
+
leaf.push(route);
|
|
69
|
+
if (!route.segments.some((s) => s.startsWith(":")))
|
|
70
|
+
statics.set("/" + route.segments.join("/"), leaf);
|
|
71
|
+
},
|
|
72
|
+
staticLeaf(path) {
|
|
73
|
+
return statics.get(path);
|
|
74
|
+
},
|
|
75
|
+
match(segments) {
|
|
76
|
+
const out = [];
|
|
77
|
+
walk(root, segments, 0, [], out);
|
|
78
|
+
return out;
|
|
79
|
+
},
|
|
80
|
+
first(segments, method) {
|
|
81
|
+
const values = [];
|
|
82
|
+
const route = seek(root, segments, 0, method, values);
|
|
83
|
+
return route ? { route, values } : null;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Context, HandlerResult, Middleware } from "../context.ts";
|
|
2
|
+
export type BodyLimitOptions = {
|
|
3
|
+
maxSize: number;
|
|
4
|
+
onError?: (c: Context) => HandlerResult | Promise<HandlerResult>;
|
|
5
|
+
};
|
|
6
|
+
export declare function bodyLimit(options: BodyLimitOptions): Middleware;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { HttpError } from "../errors.js";
|
|
2
|
+
export function bodyLimit(options) {
|
|
3
|
+
const max = options.maxSize;
|
|
4
|
+
if (typeof max !== "number" || !Number.isFinite(max) || max < 0) {
|
|
5
|
+
throw new Error(`bodyLimit(): maxSize には 0 以上のバイト数を指定してください(${String(max)})`);
|
|
6
|
+
}
|
|
7
|
+
const tooLarge = (c) => options.onError ? options.onError(c) : new Response("413 Content Too Large", { status: 413 });
|
|
8
|
+
return async function bodyLimit(c, next) {
|
|
9
|
+
const body = c.req.body;
|
|
10
|
+
if (body === null)
|
|
11
|
+
return next();
|
|
12
|
+
// Content-Length があれば、読む前に決められる(ランタイムが、それより長いボディは受け取らない)
|
|
13
|
+
const length = c.req.headers.get("content-length");
|
|
14
|
+
if (length !== null) {
|
|
15
|
+
// 数字でない値(ふつうはランタイムの HTTP パーサーがはじく)は、長さが分からないので通さない
|
|
16
|
+
if (!/^\s*\d+\s*$/.test(length))
|
|
17
|
+
return new Response("400 Bad Request", { status: 400 });
|
|
18
|
+
return Number(length) > max ? tooLarge(c) : next();
|
|
19
|
+
}
|
|
20
|
+
// ない(チャンク転送など)ときは、読みながら数える。超えたらストリームをエラーにして、それ以上は読まない
|
|
21
|
+
let exceeded = false;
|
|
22
|
+
let size = 0;
|
|
23
|
+
const reader = body.getReader();
|
|
24
|
+
const limited = new ReadableStream({
|
|
25
|
+
async pull(controller) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done)
|
|
28
|
+
return controller.close();
|
|
29
|
+
size += value.byteLength;
|
|
30
|
+
if (size > max) {
|
|
31
|
+
exceeded = true;
|
|
32
|
+
controller.error(new HttpError(413, "413 Content Too Large"));
|
|
33
|
+
await reader.cancel();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
controller.enqueue(value);
|
|
37
|
+
},
|
|
38
|
+
cancel(reason) {
|
|
39
|
+
return reader.cancel(reason);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
c.req = new Request(c.req, { body: limited, duplex: "half" });
|
|
43
|
+
const res = await next();
|
|
44
|
+
// ハンドラーがエラーを握りつぶしていても、超えていたら 413 にする
|
|
45
|
+
return exceeded ? tooLarge(c) : res;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Context, Middleware } from "../context.ts";
|
|
2
|
+
export type CorsOptions = {
|
|
3
|
+
origin?: string | readonly string[] | ((origin: string, c: Context) => boolean | string | null | undefined);
|
|
4
|
+
allowMethods?: readonly string[];
|
|
5
|
+
allowHeaders?: readonly string[];
|
|
6
|
+
exposeHeaders?: readonly string[];
|
|
7
|
+
credentials?: boolean;
|
|
8
|
+
maxAge?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function cors(options?: CorsOptions): Middleware;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { appendVary, editable } from "./response.js";
|
|
2
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH", "QUERY"];
|
|
3
|
+
export function cors(options = {}) {
|
|
4
|
+
const origin = options.origin ?? "*";
|
|
5
|
+
if (options.credentials && origin === "*") {
|
|
6
|
+
throw new Error('cors(): credentials: true のときは origin: "*" は使えません。許可するオリジンを指定してください');
|
|
7
|
+
}
|
|
8
|
+
const methods = (options.allowMethods ?? DEFAULT_METHODS).join(", ");
|
|
9
|
+
const expose = options.exposeHeaders?.join(", ");
|
|
10
|
+
// "*" 以外は、リクエストのオリジンによって答えが変わるので Vary: Origin が要る
|
|
11
|
+
const varies = origin !== "*";
|
|
12
|
+
const allowOrigin = (requestOrigin, c) => {
|
|
13
|
+
if (origin === "*")
|
|
14
|
+
return "*";
|
|
15
|
+
if (typeof origin === "string")
|
|
16
|
+
return origin === requestOrigin ? origin : null;
|
|
17
|
+
if (typeof origin === "function") {
|
|
18
|
+
const result = origin(requestOrigin, c);
|
|
19
|
+
return result === true ? requestOrigin : typeof result === "string" ? result : null;
|
|
20
|
+
}
|
|
21
|
+
return origin.includes(requestOrigin) ? requestOrigin : null;
|
|
22
|
+
};
|
|
23
|
+
return async function cors(c, next) {
|
|
24
|
+
const requestOrigin = c.req.headers.get("origin");
|
|
25
|
+
if (requestOrigin === null) {
|
|
26
|
+
if (!varies)
|
|
27
|
+
return next();
|
|
28
|
+
// 許可するオリジンがリクエストで変わるなら、Origin のないレスポンスにも Vary: Origin を付ける
|
|
29
|
+
// (付けないと、共有キャッシュが CORS ヘッダーのないこのレスポンスを、ほかのオリジンにも返してしまう)
|
|
30
|
+
const downstream = await next();
|
|
31
|
+
if (downstream.status < 200)
|
|
32
|
+
return downstream;
|
|
33
|
+
const res = editable(downstream);
|
|
34
|
+
appendVary(res.headers, "Origin");
|
|
35
|
+
return res;
|
|
36
|
+
}
|
|
37
|
+
const allowed = allowOrigin(requestOrigin, c);
|
|
38
|
+
// プリフライト(本番のリクエストの前に、ブラウザが「送ってよいか」を OPTIONS で聞いてくるもの)には、ここで答える
|
|
39
|
+
const requestMethod = c.req.headers.get("access-control-request-method");
|
|
40
|
+
if (c.req.method === "OPTIONS" && requestMethod !== null) {
|
|
41
|
+
const headers = new Headers();
|
|
42
|
+
if (varies)
|
|
43
|
+
appendVary(headers, "Origin");
|
|
44
|
+
if (allowed !== null) {
|
|
45
|
+
headers.set("access-control-allow-origin", allowed);
|
|
46
|
+
headers.set("access-control-allow-methods", methods);
|
|
47
|
+
if (options.allowHeaders) {
|
|
48
|
+
headers.set("access-control-allow-headers", options.allowHeaders.join(", "));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const requested = c.req.headers.get("access-control-request-headers");
|
|
52
|
+
if (requested)
|
|
53
|
+
headers.set("access-control-allow-headers", requested);
|
|
54
|
+
appendVary(headers, "Access-Control-Request-Headers");
|
|
55
|
+
}
|
|
56
|
+
if (options.credentials)
|
|
57
|
+
headers.set("access-control-allow-credentials", "true");
|
|
58
|
+
if (options.maxAge !== undefined)
|
|
59
|
+
headers.set("access-control-max-age", String(Math.floor(options.maxAge)));
|
|
60
|
+
}
|
|
61
|
+
return new Response(null, { status: 204, headers });
|
|
62
|
+
}
|
|
63
|
+
const downstream = await next();
|
|
64
|
+
// 101(WebSocket のアップグレード)などは作り直すと壊れるので触らない(finalize() と同じ)
|
|
65
|
+
if (downstream.status < 200)
|
|
66
|
+
return downstream;
|
|
67
|
+
const res = editable(downstream);
|
|
68
|
+
if (varies)
|
|
69
|
+
appendVary(res.headers, "Origin");
|
|
70
|
+
if (allowed !== null) {
|
|
71
|
+
res.headers.set("access-control-allow-origin", allowed);
|
|
72
|
+
if (options.credentials)
|
|
73
|
+
res.headers.set("access-control-allow-credentials", "true");
|
|
74
|
+
if (expose)
|
|
75
|
+
res.headers.set("access-control-expose-headers", expose);
|
|
76
|
+
}
|
|
77
|
+
return res;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 標準ミドルウェアが、next() のあとでレスポンスのヘッダーを足すときに使う
|
|
2
|
+
// ヘッダーを変更できるレスポンスにする
|
|
3
|
+
// fetch() や Response.redirect() の Response はヘッダーを変更できないので、そのときだけ作り直す
|
|
4
|
+
export function editable(res) {
|
|
5
|
+
try {
|
|
6
|
+
// 同じ値を入れ直すだけ。変更できなければここで TypeError になる
|
|
7
|
+
res.headers.set("vary", res.headers.get("vary") ?? "");
|
|
8
|
+
if (res.headers.get("vary") === "")
|
|
9
|
+
res.headers.delete("vary");
|
|
10
|
+
return res;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: new Headers(res.headers) });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Vary に足す(すでにあれば足さない。* なら何もしない)
|
|
17
|
+
export function appendVary(headers, ...names) {
|
|
18
|
+
const current = headers.get("vary");
|
|
19
|
+
if (current?.trim() === "*")
|
|
20
|
+
return;
|
|
21
|
+
const list = current ? current.split(",").map((v) => v.trim()).filter(Boolean) : [];
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
if (!list.some((v) => v.toLowerCase() === name.toLowerCase()))
|
|
24
|
+
list.push(name);
|
|
25
|
+
}
|
|
26
|
+
if (list.length > 0)
|
|
27
|
+
headers.set("vary", list.join(", "));
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Middleware } from "../context.ts";
|
|
2
|
+
type HeaderValue = string | boolean;
|
|
3
|
+
export type SecureHeadersOptions = {
|
|
4
|
+
contentSecurityPolicy?: HeaderValue;
|
|
5
|
+
crossOriginEmbedderPolicy?: HeaderValue;
|
|
6
|
+
crossOriginOpenerPolicy?: HeaderValue;
|
|
7
|
+
crossOriginResourcePolicy?: HeaderValue;
|
|
8
|
+
originAgentCluster?: HeaderValue;
|
|
9
|
+
referrerPolicy?: HeaderValue;
|
|
10
|
+
strictTransportSecurity?: HeaderValue;
|
|
11
|
+
xContentTypeOptions?: HeaderValue;
|
|
12
|
+
xDnsPrefetchControl?: HeaderValue;
|
|
13
|
+
xDownloadOptions?: HeaderValue;
|
|
14
|
+
xFrameOptions?: HeaderValue;
|
|
15
|
+
xPermittedCrossDomainPolicies?: HeaderValue;
|
|
16
|
+
xXssProtection?: HeaderValue;
|
|
17
|
+
};
|
|
18
|
+
export declare function secureHeaders(options?: SecureHeadersOptions): Middleware;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { editable } from "./response.js";
|
|
2
|
+
// [オプション名, ヘッダー名, 既定の値(null なら既定では付けない)]
|
|
3
|
+
const HEADERS = [
|
|
4
|
+
["contentSecurityPolicy", "content-security-policy", null],
|
|
5
|
+
["crossOriginEmbedderPolicy", "cross-origin-embedder-policy", null],
|
|
6
|
+
["crossOriginOpenerPolicy", "cross-origin-opener-policy", "same-origin"],
|
|
7
|
+
["crossOriginResourcePolicy", "cross-origin-resource-policy", "same-origin"],
|
|
8
|
+
["originAgentCluster", "origin-agent-cluster", "?1"],
|
|
9
|
+
["referrerPolicy", "referrer-policy", "no-referrer"],
|
|
10
|
+
["strictTransportSecurity", "strict-transport-security", "max-age=15552000; includeSubDomains"],
|
|
11
|
+
["xContentTypeOptions", "x-content-type-options", "nosniff"],
|
|
12
|
+
["xDnsPrefetchControl", "x-dns-prefetch-control", "off"],
|
|
13
|
+
["xDownloadOptions", "x-download-options", "noopen"],
|
|
14
|
+
["xFrameOptions", "x-frame-options", "SAMEORIGIN"],
|
|
15
|
+
["xPermittedCrossDomainPolicies", "x-permitted-cross-domain-policies", "none"],
|
|
16
|
+
["xXssProtection", "x-xss-protection", "0"],
|
|
17
|
+
];
|
|
18
|
+
export function secureHeaders(options = {}) {
|
|
19
|
+
const headers = [];
|
|
20
|
+
for (const [key, name, fallback] of HEADERS) {
|
|
21
|
+
const value = options[key];
|
|
22
|
+
if (value === false)
|
|
23
|
+
continue;
|
|
24
|
+
if (typeof value === "string")
|
|
25
|
+
headers.push([name, value]);
|
|
26
|
+
else if (fallback !== null)
|
|
27
|
+
headers.push([name, fallback]);
|
|
28
|
+
else if (value === true) {
|
|
29
|
+
throw new Error(`secureHeaders(): ${key} には既定の値がないので、文字列で指定してください`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return async function secureHeaders(c, next) {
|
|
33
|
+
// next() のあとでレスポンスに足す。ハンドラーが Response に付けたものは上書きしない
|
|
34
|
+
// (c.header() で付けたものは、このあと finalize() で入るので、そちらが勝つ)
|
|
35
|
+
const downstream = await next();
|
|
36
|
+
// 101(WebSocket のアップグレード)などは作り直すと壊れるので触らない(finalize() と同じ)
|
|
37
|
+
if (downstream.status < 200)
|
|
38
|
+
return downstream;
|
|
39
|
+
const res = editable(downstream);
|
|
40
|
+
for (const [name, value] of headers) {
|
|
41
|
+
if (!res.headers.has(name))
|
|
42
|
+
res.headers.set(name, value);
|
|
43
|
+
}
|
|
44
|
+
return res;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Handler } from "./context.ts";
|
|
2
|
+
export type Route = {
|
|
3
|
+
path: string;
|
|
4
|
+
segments: string[];
|
|
5
|
+
paramNames: string[];
|
|
6
|
+
method: string;
|
|
7
|
+
handler: Handler;
|
|
8
|
+
};
|
|
9
|
+
type Segment<Path extends string> = Path extends `${infer Head}/${infer Rest}` ? Head | Segment<Rest> : Path;
|
|
10
|
+
type ParamName<S extends string> = S extends `:${infer Name}` ? Name : never;
|
|
11
|
+
export type ExtractParams<Path extends string> = string extends Path ? Record<string, string> : {
|
|
12
|
+
[K in ParamName<Segment<Path>>]: string;
|
|
13
|
+
};
|
|
14
|
+
export type Simplify<T> = {
|
|
15
|
+
[K in keyof T]: T[K];
|
|
16
|
+
} & {};
|
|
17
|
+
export type RouteTable<K extends string, V extends object = any> = {
|
|
18
|
+
[Key in K]: Key extends `${string} ${infer Path}` ? Handler<Simplify<ExtractParams<Path>>, V> : 'ルートの書き方が違います。"GET /hello" のように、メソッドとパスの間にスペースを入れてください';
|
|
19
|
+
};
|
|
20
|
+
export declare function splitPattern(pattern: string): string[];
|
|
21
|
+
export declare function splitPath(pathname: string): string[];
|
|
22
|
+
export declare function requestPathname(req: Request): string;
|
|
23
|
+
export declare function matchPath(pattern: readonly string[], path: readonly string[]): Record<string, string> | null;
|
|
24
|
+
export declare function routeShape(segments: readonly string[]): string;
|
|
25
|
+
export declare function checkParamNames(path: string, segments: readonly string[]): void;
|
|
26
|
+
export declare function normalizeMethod(method: string): string;
|
|
27
|
+
export declare function allowedMethods(routes: readonly Route[]): string;
|
|
28
|
+
export declare function startsWithSegments(path: readonly string[], prefix: readonly string[]): boolean;
|
|
29
|
+
export declare function compareRoutes(a: Route, b: Route): number;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { HttpError, senriError } from "./errors.js";
|
|
2
|
+
// ルートのパターン("/users/:id")を分割する。末尾スラッシュや連続スラッシュは無視
|
|
3
|
+
export function splitPattern(pattern) {
|
|
4
|
+
return pattern.split("/").filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
// リクエストのパス(URL の pathname)を分割して、セグメントごとにデコードする
|
|
7
|
+
// - 末尾スラッシュや連続スラッシュは無視する("/a//b/" は "/a/b" と同じ)
|
|
8
|
+
// - "/" ごとに分割してからデコードするので、%2F が区切りと混ざることはないが、
|
|
9
|
+
// 紛らわしいので %2F(デコードすると "/" を含む)は 400 にする
|
|
10
|
+
// - デコードすると "." や ".." になるものは、パストラバーサルの元なので 400 にする
|
|
11
|
+
// (ふつうは "%2E%2E" も含めて URL の仕様で解決済みなので、ここは念のための防御)
|
|
12
|
+
export function splitPath(pathname) {
|
|
13
|
+
const segments = [];
|
|
14
|
+
for (const raw of pathname.split("/")) {
|
|
15
|
+
if (raw === "")
|
|
16
|
+
continue;
|
|
17
|
+
// "%" がなければデコードしても同じなので、しない(ほとんどのリクエストはこちら)
|
|
18
|
+
// デコードしなければ "/" は含まれない(split で分けたので)
|
|
19
|
+
let segment = raw;
|
|
20
|
+
if (raw.includes("%")) {
|
|
21
|
+
try {
|
|
22
|
+
segment = decodeURIComponent(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new HttpError(400, "400 Bad Request");
|
|
26
|
+
}
|
|
27
|
+
if (segment.includes("/"))
|
|
28
|
+
throw new HttpError(400, "400 Bad Request");
|
|
29
|
+
}
|
|
30
|
+
if (segment === "." || segment === "..") {
|
|
31
|
+
throw new HttpError(400, "400 Bad Request");
|
|
32
|
+
}
|
|
33
|
+
segments.push(segment);
|
|
34
|
+
}
|
|
35
|
+
return segments;
|
|
36
|
+
}
|
|
37
|
+
// URL の区切り文字や空白を含む Host は、ふつうのクライアントは送らない
|
|
38
|
+
const BAD_HOST = /[\s/?#@\\]/;
|
|
39
|
+
// リクエストの pathname を取り出す。おかしなリクエストは HttpError(400) にする
|
|
40
|
+
// - ランタイムが Host ヘッダーをつないで URL を作ることがある(Deno)。
|
|
41
|
+
// "Host: x/admin#" だと http://x/admin#/public になり、パスが /admin にすり替わるので弾く
|
|
42
|
+
// - Bun は Host が不正だと req.url を "/public" のような相対 URL にするので、new URL() が失敗する
|
|
43
|
+
// - 速さのため、ふつうは new URL() を使わず req.url の文字列から切り出す。
|
|
44
|
+
// ただし Deno は req.url を正規化しない("/a/../b" のまま来る)ので、"." のセグメント・%2e・\ を含むときだけ
|
|
45
|
+
// new URL() に任せて、URL の仕様どおりに解決させる(どちらで取り出しても、同じ結果になるようにしている)
|
|
46
|
+
export function requestPathname(req) {
|
|
47
|
+
const host = req.headers.get("host");
|
|
48
|
+
if (host !== null && BAD_HOST.test(host)) {
|
|
49
|
+
throw new HttpError(400, "400 Bad Request");
|
|
50
|
+
}
|
|
51
|
+
const url = req.url;
|
|
52
|
+
const scheme = url.indexOf("://");
|
|
53
|
+
if (scheme !== -1) {
|
|
54
|
+
const start = url.indexOf("/", scheme + 3);
|
|
55
|
+
if (start === -1)
|
|
56
|
+
return "/";
|
|
57
|
+
// "?" か "#" までがパス。1回の走査で、new URL() に任せるべきもの("/." と "%2e" と "\")も探す
|
|
58
|
+
let needsUrl = false;
|
|
59
|
+
let i = start;
|
|
60
|
+
for (; i < url.length; i++) {
|
|
61
|
+
const ch = url.charCodeAt(i);
|
|
62
|
+
if (ch === 63 || ch === 35)
|
|
63
|
+
break; // "?" "#"
|
|
64
|
+
if (ch === 92)
|
|
65
|
+
needsUrl = true; // "\"
|
|
66
|
+
else if (ch === 46 && url.charCodeAt(i - 1) === 47)
|
|
67
|
+
needsUrl = true; // "/."
|
|
68
|
+
else if (ch === 37 && url.charCodeAt(i + 1) === 50 && (url.charCodeAt(i + 2) | 32) === 101)
|
|
69
|
+
needsUrl = true; // "%2e" "%2E"
|
|
70
|
+
}
|
|
71
|
+
if (!needsUrl)
|
|
72
|
+
return url.slice(start, i);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return new URL(url).pathname;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new HttpError(400, "400 Bad Request");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function matchPath(pattern, path) {
|
|
82
|
+
// 個数が違ったら一致しない
|
|
83
|
+
if (pattern.length !== path.length) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// 1個ずつ比較する
|
|
87
|
+
const params = {};
|
|
88
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
89
|
+
const p = pattern[i];
|
|
90
|
+
const actual = path[i];
|
|
91
|
+
if (p.startsWith(":")) {
|
|
92
|
+
params[p.slice(1)] = actual;
|
|
93
|
+
}
|
|
94
|
+
else if (p !== actual) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return params;
|
|
99
|
+
}
|
|
100
|
+
// パターンの「形」。パラメーター名は区別しない("/users/:id" と "/users/:name" は同じ "users/:")
|
|
101
|
+
export function routeShape(segments) {
|
|
102
|
+
return segments.map((s) => (s.startsWith(":") ? ":" : s)).join("/");
|
|
103
|
+
}
|
|
104
|
+
// パラメーター名が空だったり、1つのパターンで2回使われていたりしたらエラー
|
|
105
|
+
// (2回目の値で1回目が黙って上書きされてしまうので)
|
|
106
|
+
export function checkParamNames(path, segments) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const s of segments) {
|
|
109
|
+
if (!s.startsWith(":"))
|
|
110
|
+
continue;
|
|
111
|
+
const name = s.slice(1);
|
|
112
|
+
if (name === "") {
|
|
113
|
+
throw senriError("param_empty", `Empty parameter name: ${path}`);
|
|
114
|
+
}
|
|
115
|
+
if (seen.has(name)) {
|
|
116
|
+
throw senriError("param_twice", `Parameter "${name}" used twice: ${path}`);
|
|
117
|
+
}
|
|
118
|
+
seen.add(name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// メソッド名に使える文字(RFC 9110 の token)
|
|
122
|
+
const METHOD = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
123
|
+
// 登録するメソッド名を確かめて、大文字にする("propfind" → "PROPFIND")
|
|
124
|
+
// - CONNECT はプロキシ用、TRACE / TRACK はリクエストをそのまま返すので Cookie を盗む攻撃(XST)に使われる。どれも扱わない
|
|
125
|
+
// - "ALL" は app.all() の印として使っているので、app.on() からは登録させない
|
|
126
|
+
export function normalizeMethod(method) {
|
|
127
|
+
if (!METHOD.test(method))
|
|
128
|
+
throw senriError("method_invalid", `Invalid method: ${JSON.stringify(method)}`);
|
|
129
|
+
const upper = method.toUpperCase();
|
|
130
|
+
if (upper === "CONNECT" || upper === "TRACE" || upper === "TRACK") {
|
|
131
|
+
throw senriError("method_unsupported", `${upper} is not supported`);
|
|
132
|
+
}
|
|
133
|
+
return upper;
|
|
134
|
+
}
|
|
135
|
+
// Allow ヘッダーに入れるメソッドの一覧("GET, HEAD, POST, OPTIONS")
|
|
136
|
+
// GET があれば HEAD も使える(GET にフォールバックするので)。OPTIONS はいつでも使える
|
|
137
|
+
export function allowedMethods(routes) {
|
|
138
|
+
const methods = [];
|
|
139
|
+
for (const r of routes) {
|
|
140
|
+
if (!methods.includes(r.method))
|
|
141
|
+
methods.push(r.method);
|
|
142
|
+
if (r.method === "GET" && !methods.includes("HEAD"))
|
|
143
|
+
methods.push("HEAD");
|
|
144
|
+
}
|
|
145
|
+
if (!methods.includes("OPTIONS"))
|
|
146
|
+
methods.push("OPTIONS");
|
|
147
|
+
return methods.join(", ");
|
|
148
|
+
}
|
|
149
|
+
// path の先頭が prefix と一致するか。セグメント単位で比べるので "/admin" は "/administrator" にはマッチしない
|
|
150
|
+
// プレフィックスの ":id" のようなパラメーターは、どんなセグメントにも合う(matchPath と同じ判定)
|
|
151
|
+
export function startsWithSegments(path, prefix) {
|
|
152
|
+
if (path.length < prefix.length)
|
|
153
|
+
return false;
|
|
154
|
+
return prefix.every((p, i) => p.startsWith(":") || p === path[i]);
|
|
155
|
+
}
|
|
156
|
+
// sort 用の比較関数。どの2つを比べても答えが矛盾しない(=並び順が一意に決まる)ようにする
|
|
157
|
+
export function compareRoutes(a, b) {
|
|
158
|
+
const aParts = a.segments;
|
|
159
|
+
const bParts = b.segments;
|
|
160
|
+
// まずセグメント数で分ける。数が違うルート同士はそもそも同じパスにマッチしないので、
|
|
161
|
+
// ここで順番を決めてしまっても優先順位には影響しない
|
|
162
|
+
// (以前はここで 0 = 「同じ」と答えていたため、sort の結果が登録順に左右されていた)
|
|
163
|
+
if (aParts.length !== bParts.length) {
|
|
164
|
+
return aParts.length - bParts.length;
|
|
165
|
+
}
|
|
166
|
+
for (let i = 0; i < aParts.length; i++) {
|
|
167
|
+
const aIsParam = aParts[i].startsWith(":");
|
|
168
|
+
const bIsParam = bParts[i].startsWith(":");
|
|
169
|
+
// aがパラメータでbがパラメータでない場合、bを優先する
|
|
170
|
+
if (aIsParam && !bIsParam)
|
|
171
|
+
return 1;
|
|
172
|
+
// aがパラメータでなくbがパラメータの場合、aを優先する
|
|
173
|
+
if (!aIsParam && bIsParam)
|
|
174
|
+
return -1;
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|