@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.
- package/LICENSE +21 -0
- package/README.md +331 -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 +49 -0
- package/dist/src/app.js +375 -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 +95 -0
- package/dist/src/connection.d.ts +3 -0
- package/dist/src/connection.js +49 -0
- package/dist/src/context.d.ts +60 -0
- package/dist/src/context.js +254 -0
- package/dist/src/cookie.d.ts +13 -0
- package/dist/src/cookie.js +61 -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/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 +82 -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 +6 -0
- package/dist/src/dev/index.js +11 -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/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/render.d.ts +5 -0
- package/dist/src/dev/render.js +42 -0
- package/dist/src/dev/runtime.d.ts +3 -0
- package/dist/src/dev/runtime.js +45 -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/errors.d.ts +23 -0
- package/dist/src/errors.js +136 -0
- package/dist/src/group.d.ts +38 -0
- package/dist/src/group.js +83 -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 +10 -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 +4 -0
- package/dist/src/runtime.js +49 -0
- package/dist/src/validate.d.ts +47 -0
- package/dist/src/validate.js +87 -0
- package/package.json +74 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { normalizeMethod, splitPattern } from "./router.js";
|
|
2
|
+
import { checkSchemas, withValidation } from "./validate.js";
|
|
3
|
+
// 2つ目の引数がスキーマなら、確かめてからハンドラーを呼ぶものにする(app.ts と同じ)
|
|
4
|
+
export function routeHandler(path, a, b) {
|
|
5
|
+
if (typeof a === "function")
|
|
6
|
+
return a;
|
|
7
|
+
if (typeof b !== "function")
|
|
8
|
+
throw new TypeError(`${path}: ハンドラーがありません`);
|
|
9
|
+
checkSchemas(path, a);
|
|
10
|
+
return withValidation(a, b);
|
|
11
|
+
}
|
|
12
|
+
const entries = new WeakMap();
|
|
13
|
+
export function joinPath(...parts) {
|
|
14
|
+
return "/" + parts.flatMap(splitPattern).join("/");
|
|
15
|
+
}
|
|
16
|
+
// ルーターに登録されたものを、プレフィックスを付けたパスで返す
|
|
17
|
+
export function groupEntries(router) {
|
|
18
|
+
const list = entries.get(router);
|
|
19
|
+
if (!list)
|
|
20
|
+
throw new TypeError("app.route() には createRouter() で作ったルーターを渡してください");
|
|
21
|
+
return list("");
|
|
22
|
+
}
|
|
23
|
+
// V は c.set / c.get で使う変数の型。アプリと同じものを指定する: createRouter<"/admin", Vars>("/admin")
|
|
24
|
+
export function createRouter(prefix) {
|
|
25
|
+
// 子のルーターは、つなげた時点ではなく app.route の時点で中身を読む(あとから足したルートも入る)
|
|
26
|
+
const items = [];
|
|
27
|
+
const push = (name, path, handler) => {
|
|
28
|
+
items.push({ kind: "entry", entry: { kind: "route", method: name, path, handler } });
|
|
29
|
+
};
|
|
30
|
+
const method = (name) => ((path, a, b) => push(name, path, routeHandler(path, a, b)));
|
|
31
|
+
const router = {
|
|
32
|
+
prefix,
|
|
33
|
+
use(pathOrMw, mw) {
|
|
34
|
+
const path = typeof pathOrMw === "string" ? pathOrMw : "/";
|
|
35
|
+
const fn = typeof pathOrMw === "string" ? mw : pathOrMw;
|
|
36
|
+
if (!fn)
|
|
37
|
+
throw new Error(`router.use("${path}", ...) にミドルウェアがありません`);
|
|
38
|
+
items.push({ kind: "entry", entry: { kind: "use", path, mw: fn } });
|
|
39
|
+
},
|
|
40
|
+
get: method("GET"),
|
|
41
|
+
post: method("POST"),
|
|
42
|
+
put: method("PUT"),
|
|
43
|
+
patch: method("PATCH"),
|
|
44
|
+
delete: method("DELETE"),
|
|
45
|
+
all: method("ALL"),
|
|
46
|
+
head: method("HEAD"),
|
|
47
|
+
options: method("OPTIONS"),
|
|
48
|
+
on: ((methods, path, a, b) => {
|
|
49
|
+
const list = typeof methods === "string" ? [methods] : methods;
|
|
50
|
+
if (list.length === 0)
|
|
51
|
+
throw new Error(`router.on(..., "${path}") にメソッドがありません`);
|
|
52
|
+
const handler = routeHandler(path, a, b);
|
|
53
|
+
for (const m of list) {
|
|
54
|
+
if (m.toUpperCase() === "ALL")
|
|
55
|
+
throw new Error("すべてのメソッドで動かすには all() を使ってください");
|
|
56
|
+
// 名前の確認(CONNECT などの拒否)は、ここで先にしておく(app.route まで待たない)
|
|
57
|
+
push(normalizeMethod(m), path, handler);
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
route(child) {
|
|
61
|
+
if (!entries.has(child))
|
|
62
|
+
throw new TypeError("route() には createRouter() で作ったルーターを渡してください");
|
|
63
|
+
items.push({ kind: "router", router: child });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
entries.set(router, (base) => {
|
|
68
|
+
// ルーターが自分自身を(間接的にでも)含んでいると終わらないので止める
|
|
69
|
+
if (seen.has(router))
|
|
70
|
+
throw new Error(`ルーター ${prefix} が自分自身を含んでいます`);
|
|
71
|
+
seen.add(router);
|
|
72
|
+
try {
|
|
73
|
+
const here = joinPath(base, prefix);
|
|
74
|
+
return items.flatMap((item) => item.kind === "router"
|
|
75
|
+
? entries.get(item.router)(here)
|
|
76
|
+
: [{ ...item.entry, path: joinPath(here, item.entry.path) }]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
seen.delete(router);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return router;
|
|
83
|
+
}
|
|
@@ -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 AppOptions, type MountableApp } 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 { Timing } 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,10 @@
|
|
|
1
|
+
export { createApp } from "./app.js";
|
|
2
|
+
export { createRouter } from "./group.js";
|
|
3
|
+
export { Context, } from "./context.js";
|
|
4
|
+
export { HttpError } from "./errors.js";
|
|
5
|
+
export { ValidationError, } from "./validate.js";
|
|
6
|
+
export { logger, showRoutes } from "./logger.js";
|
|
7
|
+
export { health } from "./health.js";
|
|
8
|
+
export { cors } from "./middleware/cors.js";
|
|
9
|
+
export { secureHeaders } from "./middleware/secure-headers.js";
|
|
10
|
+
export { bodyLimit } from "./middleware/body-limit.js";
|
|
@@ -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 {};
|
|
@@ -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
|
+
}
|