@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,50 @@
|
|
|
1
|
+
import { buildErrorPageData } from "../error-page.js";
|
|
2
|
+
import { maskHeaders, maskQuery } from "./mask.js";
|
|
3
|
+
function requestPath(c) {
|
|
4
|
+
let query = "";
|
|
5
|
+
try {
|
|
6
|
+
// ?token=... のような機密値は伏せる
|
|
7
|
+
query = maskQuery(new URL(c.req.url).search);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// URL として読めなくても記録はする
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return { path: c.path, query };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// デコードできないパス(400)は、届いたままのものを出す
|
|
17
|
+
return { path: c.req.url, query };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function buildRecord(input) {
|
|
21
|
+
const { id, time, c, res, layerNames } = input;
|
|
22
|
+
// タイムラインの 0 は、いちばん外側の層に入った時刻にする
|
|
23
|
+
// (c.timings の start は app.fetch の開始が基準で、ルートのマッチなど、層に入る前の準備の分だけずれている。
|
|
24
|
+
// 全体の時間 durationMs はいちばん外側の層の total なので、基準をそろえないと棒がはみ出す)
|
|
25
|
+
const base = c.timings[0]?.start ?? 0;
|
|
26
|
+
const layers = layerNames.map(({ name, order }, depth) => {
|
|
27
|
+
const t = c.timings[depth];
|
|
28
|
+
// 計測がない = そこまで届かなかった
|
|
29
|
+
if (!t)
|
|
30
|
+
return { name, order, depth, start: 0, total: 0, self: 0, state: "skipped" };
|
|
31
|
+
const state = t.failed ? "failed" : t.stopped ? "stopped" : "ok";
|
|
32
|
+
return { name, order, depth, start: t.start - base, total: t.total, self: t.self, state };
|
|
33
|
+
});
|
|
34
|
+
// 5xx で、例外が残っていれば(意図した 4xx の HttpError は c.error に入らない)、エラーページと同じ中身を取っておく
|
|
35
|
+
const error = res.status >= 500 && c.error !== undefined ? await buildErrorPageData(c.error, c, res) : null;
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
time,
|
|
39
|
+
method: c.req.method,
|
|
40
|
+
...requestPath(c),
|
|
41
|
+
route: c.route ?? null,
|
|
42
|
+
params: c.params,
|
|
43
|
+
status: res.status,
|
|
44
|
+
durationMs: c.timings[0]?.total ?? 0,
|
|
45
|
+
layers,
|
|
46
|
+
requestHeaders: maskHeaders(c.req.headers),
|
|
47
|
+
responseHeaders: maskHeaders(res.headers),
|
|
48
|
+
error,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DashboardPageData, RequestSummary } from "../page-data.ts";
|
|
2
|
+
import type { Dashboard } from "./index.ts";
|
|
3
|
+
import type { DashboardRecord } from "./record.ts";
|
|
4
|
+
export declare function summarize(r: DashboardRecord): RequestSummary;
|
|
5
|
+
export declare function applies(prefix: string[] | null, route: string[]): "yes" | "no" | "maybe";
|
|
6
|
+
export declare function pageData(d: Dashboard): DashboardPageData;
|
|
7
|
+
export declare function handleDashboard(d: Dashboard, req: Request, segments: readonly string[], events: (req: Request) => Response): Promise<Response>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// /_senri 以下へのリクエストを処理する(ページ、JSON の API、SSE、クリア)
|
|
2
|
+
// 利用者のミドルウェアは通さず、記録もしない(app.ts が先にここへ回す)
|
|
3
|
+
import { splitPattern } from "../../router.js";
|
|
4
|
+
import { errorFallback } from "../fallback.js";
|
|
5
|
+
import { PAGE_HTML } from "../page.generated.js";
|
|
6
|
+
import { renderPage } from "../render.js";
|
|
7
|
+
import { sameOrigin } from "./access.js";
|
|
8
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
11
|
+
});
|
|
12
|
+
const html = (body) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
13
|
+
const notFound = () => json({ error: "not found" }, 404);
|
|
14
|
+
export function summarize(r) {
|
|
15
|
+
return {
|
|
16
|
+
id: r.id,
|
|
17
|
+
time: r.time,
|
|
18
|
+
method: r.method,
|
|
19
|
+
path: r.path,
|
|
20
|
+
status: r.status,
|
|
21
|
+
durationMs: r.durationMs,
|
|
22
|
+
hasError: r.error !== null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// パス指定のミドルウェア(prefix)が、ルートのパターンにかかるか
|
|
26
|
+
// セグメントごとに比べ、ルートのほうがパラメーター(":id")なら「パスによる」
|
|
27
|
+
export function applies(prefix, route) {
|
|
28
|
+
if (prefix === null)
|
|
29
|
+
return "yes";
|
|
30
|
+
if (route.length < prefix.length)
|
|
31
|
+
return "no";
|
|
32
|
+
let maybe = false;
|
|
33
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
34
|
+
if (route[i].startsWith(":"))
|
|
35
|
+
maybe = true;
|
|
36
|
+
else if (route[i] !== prefix[i])
|
|
37
|
+
return "no";
|
|
38
|
+
}
|
|
39
|
+
return maybe ? "maybe" : "yes";
|
|
40
|
+
}
|
|
41
|
+
function routeRows(d) {
|
|
42
|
+
const middlewares = d.source.middlewares();
|
|
43
|
+
return d.source.routes().map((r) => {
|
|
44
|
+
const segments = splitPattern(r.path);
|
|
45
|
+
return {
|
|
46
|
+
method: r.method,
|
|
47
|
+
path: r.path,
|
|
48
|
+
middlewares: middlewares.map((m) => ({
|
|
49
|
+
name: m.name,
|
|
50
|
+
applies: applies(m.path === null ? null : splitPattern(m.path), segments),
|
|
51
|
+
})),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function middlewareRows(d) {
|
|
56
|
+
const records = d.log.list();
|
|
57
|
+
return d.source.middlewares().map((m, i) => {
|
|
58
|
+
// 名前ではなく登録順(order)で数える(同じ名前のミドルウェアが2つあっても、別々に集計される)
|
|
59
|
+
const order = i + 1;
|
|
60
|
+
const selfs = records.flatMap((r) => r.layers.filter((l) => l.order === order && l.state !== "skipped").map((l) => l.self));
|
|
61
|
+
return {
|
|
62
|
+
order,
|
|
63
|
+
name: m.name,
|
|
64
|
+
anonymous: m.anonymous,
|
|
65
|
+
path: m.path,
|
|
66
|
+
runs: selfs.length,
|
|
67
|
+
avgSelfMs: selfs.length === 0 ? null : selfs.reduce((a, b) => a + b, 0) / selfs.length,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function pageData(d) {
|
|
72
|
+
return {
|
|
73
|
+
kind: "dashboard",
|
|
74
|
+
max: d.log.max,
|
|
75
|
+
requests: d.log.list().map(summarize),
|
|
76
|
+
routes: routeRows(d),
|
|
77
|
+
middlewares: middlewareRows(d),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// segments: c.segments の "_senri" より後ろ(["api", "requests", "12"] など)
|
|
81
|
+
export async function handleDashboard(d, req, segments, events) {
|
|
82
|
+
const [first, second, id, sub] = segments;
|
|
83
|
+
const method = req.method;
|
|
84
|
+
// ページ本体
|
|
85
|
+
if (segments.length === 0) {
|
|
86
|
+
return method === "GET" || method === "HEAD" ? html(renderPage(PAGE_HTML, pageData(d))) : notFound();
|
|
87
|
+
}
|
|
88
|
+
if (first !== "api")
|
|
89
|
+
return notFound();
|
|
90
|
+
if (second === "clear" && segments.length === 2) {
|
|
91
|
+
if (method !== "POST")
|
|
92
|
+
return notFound();
|
|
93
|
+
// ほかのサイトのページから、こっそり POST させられないように
|
|
94
|
+
if (!sameOrigin(req))
|
|
95
|
+
return json({ error: "forbidden" }, 403);
|
|
96
|
+
d.log.clear();
|
|
97
|
+
return new Response(null, { status: 204 });
|
|
98
|
+
}
|
|
99
|
+
if (method !== "GET")
|
|
100
|
+
return notFound();
|
|
101
|
+
if (second === "events" && segments.length === 2)
|
|
102
|
+
return events(req);
|
|
103
|
+
if (second === "routes" && segments.length === 2)
|
|
104
|
+
return json(routeRows(d));
|
|
105
|
+
if (second === "middleware" && segments.length === 2)
|
|
106
|
+
return json(middlewareRows(d));
|
|
107
|
+
if (second === "requests") {
|
|
108
|
+
if (segments.length === 2)
|
|
109
|
+
return json(d.log.list().map(summarize));
|
|
110
|
+
const record = id === undefined ? undefined : d.log.get(id);
|
|
111
|
+
if (!record)
|
|
112
|
+
return notFound();
|
|
113
|
+
if (segments.length === 3)
|
|
114
|
+
return json(record);
|
|
115
|
+
// 500 のときのエラーページ(記録しておいた中身から作り直す)
|
|
116
|
+
if (sub === "error" && segments.length === 4 && record.error) {
|
|
117
|
+
return html(renderPage(PAGE_HTML, record.error, errorFallback(record.error)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return notFound();
|
|
121
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RequestLog } from "./log.ts";
|
|
2
|
+
import type { DashboardRecord } from "./record.ts";
|
|
3
|
+
export type EventStreamOptions = {
|
|
4
|
+
heartbeatMs?: number;
|
|
5
|
+
maxQueued?: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function eventStream(log: RequestLog<DashboardRecord>, req: Request, options?: EventStreamOptions): Response;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { summarize } from "./routes.js";
|
|
2
|
+
export function eventStream(log, req, options = {}) {
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const heartbeatMs = options.heartbeatMs ?? 5000;
|
|
5
|
+
const maxQueued = options.maxQueued ?? 32;
|
|
6
|
+
let closed = false;
|
|
7
|
+
let cleanup = () => { };
|
|
8
|
+
const stream = new ReadableStream({
|
|
9
|
+
start(controller) {
|
|
10
|
+
const send = (text) => {
|
|
11
|
+
if (closed)
|
|
12
|
+
return;
|
|
13
|
+
// desiredSize は「あと何件ためられるか」。0 以下なら、読まれないまま上限までたまっている
|
|
14
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
15
|
+
cleanup();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
controller.enqueue(encoder.encode(text));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// もう閉じていて書けない(クライアントが切れた)
|
|
23
|
+
cleanup();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const unsubscribe = log.subscribe((event) => {
|
|
27
|
+
if (event.type === "request") {
|
|
28
|
+
// JSON.stringify は改行をエスケープするので、data は必ず1行になる
|
|
29
|
+
send(`event: request\ndata: ${JSON.stringify(summarize(event.item))}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
send("event: clear\ndata: {}\n\n");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const timer = setInterval(() => send(": ping\n\n"), heartbeatMs);
|
|
36
|
+
// テストなどで、この ping のせいでプロセスが終われなくならないように(Node / Bun)
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
const onAbort = () => cleanup();
|
|
39
|
+
// 後片付けは、接続が切れたとき(req.signal)と、読む側がやめたとき(cancel)のどちらからでも1回だけ
|
|
40
|
+
cleanup = () => {
|
|
41
|
+
if (closed)
|
|
42
|
+
return;
|
|
43
|
+
closed = true;
|
|
44
|
+
unsubscribe();
|
|
45
|
+
clearInterval(timer);
|
|
46
|
+
req.signal.removeEventListener("abort", onAbort);
|
|
47
|
+
try {
|
|
48
|
+
controller.close();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// すでに閉じている
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if (req.signal.aborted) {
|
|
55
|
+
cleanup();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
req.signal.addEventListener("abort", onAbort, { once: true });
|
|
59
|
+
send(`retry: 2000\n\n`);
|
|
60
|
+
},
|
|
61
|
+
cancel() {
|
|
62
|
+
cleanup();
|
|
63
|
+
},
|
|
64
|
+
}, new CountQueuingStrategy({ highWaterMark: maxQueued }));
|
|
65
|
+
return new Response(stream, {
|
|
66
|
+
headers: {
|
|
67
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
68
|
+
"cache-control": "no-store",
|
|
69
|
+
// 間にプロキシ(nginx など)があっても、ためずにすぐ流してもらう
|
|
70
|
+
"x-accel-buffering": "no",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type EditorId = "vscode" | "cursor" | "webstorm" | "zed" | "copy";
|
|
2
|
+
export declare const EDITORS: {
|
|
3
|
+
id: EditorId;
|
|
4
|
+
label: string;
|
|
5
|
+
experimental?: boolean;
|
|
6
|
+
}[];
|
|
7
|
+
export type Location = {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
col: number;
|
|
11
|
+
root: string | null;
|
|
12
|
+
};
|
|
13
|
+
export declare function editorUrl(editor: EditorId, loc: Location): string | null;
|
|
14
|
+
export declare function copyText({ file, line, col }: Location): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// エディタでファイルを開く URL を作る。サーバーでもブラウザ(エラーページ)でも使うので、
|
|
2
|
+
// ランタイムに頼らない純粋な関数だけを置く
|
|
3
|
+
//
|
|
4
|
+
// 確かめた形式(2026-10):
|
|
5
|
+
// - VS Code: vscode://file/<絶対パス>:行:列
|
|
6
|
+
// - Cursor: cursor://file/<絶対パス>:行:列 (VS Code と同じ形。公式ドキュメントはなく、コミュニティの情報)
|
|
7
|
+
// - Zed: zed://file/<絶対パス>:行:列 (Zed のソース open_listener.rs で確認)
|
|
8
|
+
// - WebStorm: jetbrains://web-storm/navigate/reference?project=<名前>&path=<相対パス>:行:列
|
|
9
|
+
// JetBrains Toolbox が必要で、公式ドキュメントはない(実験的)
|
|
10
|
+
export const EDITORS = [
|
|
11
|
+
{ id: "vscode", label: "VS Code" },
|
|
12
|
+
{ id: "cursor", label: "Cursor" },
|
|
13
|
+
{ id: "webstorm", label: "WebStorm", experimental: true },
|
|
14
|
+
{ id: "zed", label: "Zed" },
|
|
15
|
+
{ id: "copy", label: "コピーのみ" },
|
|
16
|
+
];
|
|
17
|
+
// 1つずつのフォルダ名をエンコードする("#" や "?" が URL の区切りと間違えられないように)
|
|
18
|
+
// Windows のドライブ "C:" の ":" はそのまま残す("/C:/..." と分けると2番目に来る)
|
|
19
|
+
function encodePath(path) {
|
|
20
|
+
return path
|
|
21
|
+
.split("/")
|
|
22
|
+
.map((part, i) => (i <= 1 && /^[A-Za-z]:$/.test(part) ? part : encodeURIComponent(part)))
|
|
23
|
+
.join("/");
|
|
24
|
+
}
|
|
25
|
+
function fileUrl(scheme, { file, line, col }) {
|
|
26
|
+
// POSIX は "/home/..." なのでそのまま、Windows は "C:/..." なので "/" を足す
|
|
27
|
+
const path = file.startsWith("/") ? file : `/${file}`;
|
|
28
|
+
return `${scheme}://file${encodePath(path)}:${line}:${col}`;
|
|
29
|
+
}
|
|
30
|
+
function relativeTo(file, root) {
|
|
31
|
+
const fold = (p) => (/^[A-Za-z]:\//.test(p) ? p.toLowerCase() : p);
|
|
32
|
+
const r = root.replace(/\/$/, "");
|
|
33
|
+
if (!fold(file).startsWith(`${fold(r)}/`))
|
|
34
|
+
return null;
|
|
35
|
+
return file.slice(r.length + 1);
|
|
36
|
+
}
|
|
37
|
+
export function editorUrl(editor, loc) {
|
|
38
|
+
switch (editor) {
|
|
39
|
+
case "vscode":
|
|
40
|
+
case "cursor":
|
|
41
|
+
case "zed":
|
|
42
|
+
return fileUrl(editor, loc);
|
|
43
|
+
case "webstorm": {
|
|
44
|
+
// Toolbox の形式は「プロジェクト名 + その中の相対パス」で指定する
|
|
45
|
+
if (!loc.root)
|
|
46
|
+
return null;
|
|
47
|
+
const rel = relativeTo(loc.file, loc.root);
|
|
48
|
+
if (rel === null)
|
|
49
|
+
return null;
|
|
50
|
+
const project = loc.root.replace(/\/$/, "").split("/").at(-1);
|
|
51
|
+
return (`jetbrains://web-storm/navigate/reference?project=${encodeURIComponent(project)}` +
|
|
52
|
+
`&path=${encodePath(rel)}:${loc.line}:${loc.col}`);
|
|
53
|
+
}
|
|
54
|
+
case "copy":
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// コピーボタンで入れる文字列。多くのエディタの「ファイルを開く」にそのまま貼れる
|
|
59
|
+
export function copyText({ file, line, col }) {
|
|
60
|
+
return `${file}:${line}:${col}`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as runtime from "../runtime.js";
|
|
2
|
+
export function guessEditor(env = runtime.env) {
|
|
3
|
+
// Cursor は TERM_PROGRAM=vscode も付くので、先に見る
|
|
4
|
+
if (env("CURSOR_TRACE_ID"))
|
|
5
|
+
return { id: "cursor", detected: true };
|
|
6
|
+
const termProgram = env("TERM_PROGRAM");
|
|
7
|
+
if (termProgram === "vscode")
|
|
8
|
+
return { id: "vscode", detected: true };
|
|
9
|
+
if (termProgram === "zed" || env("ZED_TERM") === "true")
|
|
10
|
+
return { id: "zed", detected: true };
|
|
11
|
+
if (env("TERMINAL_EMULATOR") === "JetBrains-JediTerm")
|
|
12
|
+
return { id: "webstorm", detected: true };
|
|
13
|
+
return { id: "vscode", detected: false };
|
|
14
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { conversionOriginOf, HttpError } from "../errors.js";
|
|
2
|
+
import { guessEditor } from "./editor.js";
|
|
3
|
+
import { hintFor } from "./hints.js";
|
|
4
|
+
import * as runtime from "../runtime.js";
|
|
5
|
+
import { dashboardLinkOf } from "./dashboard/links.js";
|
|
6
|
+
import { readCodeFrame } from "./source.js";
|
|
7
|
+
import { parseStack } from "./stack.js";
|
|
8
|
+
// Bun などは statusText が空のことがあるので、よく使うものだけ用意しておく
|
|
9
|
+
const STATUS_TEXT = {
|
|
10
|
+
500: "Internal Server Error",
|
|
11
|
+
501: "Not Implemented",
|
|
12
|
+
502: "Bad Gateway",
|
|
13
|
+
503: "Service Unavailable",
|
|
14
|
+
504: "Gateway Timeout",
|
|
15
|
+
};
|
|
16
|
+
// 本番(dev: false)で、このエラーのときに返る本文(errors.ts の defaultOnError と同じ考え方)
|
|
17
|
+
function productionText(err) {
|
|
18
|
+
return err instanceof HttpError ? err.message : "500 Internal Server Error";
|
|
19
|
+
}
|
|
20
|
+
function toPageFrame(frame, root) {
|
|
21
|
+
const prefix = root ? `${root.replace(/\/$/, "")}/` : null;
|
|
22
|
+
const fold = (p) => (/^[A-Za-z]:\//.test(p) ? p.toLowerCase() : p);
|
|
23
|
+
const display = prefix && fold(frame.file).startsWith(fold(prefix)) ? frame.file.slice(prefix.length) : frame.file;
|
|
24
|
+
return { ...frame, display };
|
|
25
|
+
}
|
|
26
|
+
function requestPath(c) {
|
|
27
|
+
try {
|
|
28
|
+
return c.path;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// デコードできないパス(400)でも、ページは出せるように
|
|
32
|
+
return c.req.url;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function buildErrorPageData(err, c, res) {
|
|
36
|
+
const root = runtime.cwd();
|
|
37
|
+
const error = err instanceof Error ? { name: err.name, message: err.message } : { name: "throw", message: String(err) };
|
|
38
|
+
const stack = err instanceof Error && typeof err.stack === "string" ? parseStack(err.stack) : [];
|
|
39
|
+
const frames = stack.map((f) => toPageFrame(f, root));
|
|
40
|
+
// 返した値をレスポンスにする途中で失敗したときは、スタックの利用者のコードは
|
|
41
|
+
// 「返した場所」ではなく外側のミドルウェアの `await next()` なので、場所としては出さない
|
|
42
|
+
const origin = conversionOriginOf(err);
|
|
43
|
+
const first = origin ? null : (stack.find((f) => f.kind === "user") ?? null);
|
|
44
|
+
return {
|
|
45
|
+
kind: "error",
|
|
46
|
+
status: res.status,
|
|
47
|
+
statusText: res.statusText || STATUS_TEXT[res.status] || "",
|
|
48
|
+
// いちばん外側の層の total が、リクエスト全体の時間
|
|
49
|
+
durationMs: c.timings[0]?.total ?? null,
|
|
50
|
+
error,
|
|
51
|
+
origin,
|
|
52
|
+
location: first ? toPageFrame(first, root) : null,
|
|
53
|
+
code: first ? await readCodeFrame(first, { root }) : null,
|
|
54
|
+
hint: hintFor(err, stack),
|
|
55
|
+
frames,
|
|
56
|
+
request: {
|
|
57
|
+
method: c.req.method,
|
|
58
|
+
path: requestPath(c),
|
|
59
|
+
route: c.route ?? null,
|
|
60
|
+
params: c.params,
|
|
61
|
+
userAgent: c.req.headers.get("user-agent"),
|
|
62
|
+
},
|
|
63
|
+
layers: c.timings.map((t) => ({ name: t.name, self: t.self, total: t.total, failed: t.failed })),
|
|
64
|
+
editor: guessEditor(),
|
|
65
|
+
root,
|
|
66
|
+
production: productionText(err),
|
|
67
|
+
dashboardUrl: dashboardLinkOf(c),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 本体が投げるエラー(短い英語 + code)に、開発モードで日本語の説明を付ける
|
|
2
|
+
// 本体を小さくするため、長い説明はこちらに置いている(src/errors.ts の senriError)
|
|
3
|
+
import { errorCode } from "../errors.js";
|
|
4
|
+
const JA = {
|
|
5
|
+
route_duplicate: "同じメソッド・同じ形のパスが、すでに登録されています(パラメーター名だけが違うものも同じとみなします)。あとから登録したほうは呼ばれません",
|
|
6
|
+
param_empty: "パラメーター名が空です(\":\" のあとに名前を書いてください)",
|
|
7
|
+
param_twice: "1つのパスで、同じパラメーター名が2回使われています",
|
|
8
|
+
method_invalid: "メソッド名に使えない文字があります",
|
|
9
|
+
method_unsupported: "CONNECT / TRACE / TRACK は扱いません(登録できません)",
|
|
10
|
+
no_handler: "ハンドラーがありません。app.get(path, { params, query, body }, handler) のように、スキーマのあとにハンドラーを渡してください",
|
|
11
|
+
no_middleware: "ミドルウェアがありません",
|
|
12
|
+
no_method: "メソッドがありません",
|
|
13
|
+
on_all: "すべてのメソッドで動かすには app.all() を使ってください",
|
|
14
|
+
route_key: "ルートの書き方が違います。\"GET /hello\" のように、メソッドとパスの間にスペースを入れてください",
|
|
15
|
+
not_app: "app.route(prefix, app) には createApp() で作ったアプリを渡してください",
|
|
16
|
+
not_router: "createRouter() で作ったルーターを渡してください",
|
|
17
|
+
router_cycle: "ルーターが自分自身を含んでいます",
|
|
18
|
+
cookie_name: "Cookie の名前に使えない文字があります",
|
|
19
|
+
no_valid: "c.valid() で読めるのは、ルートに { query } や { body } のスキーマを渡したものだけです",
|
|
20
|
+
no_execution_ctx: "c.executionCtx は Cloudflare Workers で、app.fetch(req, env, ctx) として呼ばれたときだけ使えます",
|
|
21
|
+
returned_function: "ハンドラーが関数を返しました。呼び出し忘れではありませんか?",
|
|
22
|
+
returned_nothing: "ハンドラーが何も返していません(undefined / null)",
|
|
23
|
+
next_twice: "next() が2回呼ばれました。1つのミドルウェアで呼べるのは1回だけです",
|
|
24
|
+
no_response: "ミドルウェアが何も返さず、next() も呼んでいません",
|
|
25
|
+
};
|
|
26
|
+
// 日本語の説明(なければ null)
|
|
27
|
+
export function explainText(err) {
|
|
28
|
+
const code = errorCode(err);
|
|
29
|
+
return code ? (JA[code] ?? null) : null;
|
|
30
|
+
}
|
|
31
|
+
// エラーの message の前に、日本語の説明を付ける(code や種類はそのまま)
|
|
32
|
+
export function explain(err) {
|
|
33
|
+
const ja = explainText(err);
|
|
34
|
+
if (ja && err instanceof Error && !err.message.startsWith(ja))
|
|
35
|
+
err.message = `${ja}\n${err.message}`;
|
|
36
|
+
return err;
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// スクリプトが動かないときに見える、最低限の HTML
|
|
2
|
+
//
|
|
3
|
+
// ページは Vue(インラインのスクリプト)で描画するので、利用者のミドルウェアが
|
|
4
|
+
// Content-Security-Policy を付けていると、スクリプトが止められて真っ白になってしまう。
|
|
5
|
+
// そこで <div id="app"> の中に、サーバー側で作った簡易表示を最初から入れておく。
|
|
6
|
+
// Vue が動けば丸ごと置き換わり、止められればこれがそのまま見える
|
|
7
|
+
// (中身は全部 escapeHtml を通す。エラーメッセージやパスは攻撃者が決められるので)
|
|
8
|
+
import { escapeHtml } from "./html.js";
|
|
9
|
+
import { originText } from "./messages.js";
|
|
10
|
+
const NOTICE = "このページの詳しい表示には JavaScript が必要です。Content-Security-Policy などでスクリプトが止められているときは、この簡易表示になります。";
|
|
11
|
+
export function errorFallback(d) {
|
|
12
|
+
const where = d.origin
|
|
13
|
+
? originText(d.origin)
|
|
14
|
+
: d.location
|
|
15
|
+
? `${d.location.display}:${d.location.line}:${d.location.col}`
|
|
16
|
+
: null;
|
|
17
|
+
const frames = d.frames
|
|
18
|
+
.filter((f) => f.kind !== "senri" && f.kind !== "runtime")
|
|
19
|
+
.map((f) => `${f.fn} ${f.display}:${f.line}:${f.col}`)
|
|
20
|
+
.join("\n");
|
|
21
|
+
return [
|
|
22
|
+
"<main>",
|
|
23
|
+
`<p>${escapeHtml(`${d.request.method} ${d.request.path} · ${d.status} ${d.statusText}`)}</p>`,
|
|
24
|
+
`<h1>${escapeHtml(`${d.error.name}: ${d.error.message}`)}</h1>`,
|
|
25
|
+
where ? `<p>--> ${escapeHtml(where)}</p>` : "",
|
|
26
|
+
frames ? `<pre>${escapeHtml(frames)}</pre>` : "",
|
|
27
|
+
`<p>${escapeHtml(NOTICE)}</p>`,
|
|
28
|
+
"</main>",
|
|
29
|
+
].join("");
|
|
30
|
+
}
|
|
31
|
+
export function notFoundFallback(d) {
|
|
32
|
+
return [
|
|
33
|
+
"<main>",
|
|
34
|
+
"<p>404 Not Found</p>",
|
|
35
|
+
`<h1>${escapeHtml(`${d.method} ${d.path}`)} は見つかりませんでした</h1>`,
|
|
36
|
+
d.suggestion ? `<p>もしかして: ${escapeHtml(`${d.suggestion.method} ${d.suggestion.path}`)}</p>` : "",
|
|
37
|
+
`<p>${escapeHtml(NOTICE)}</p>`,
|
|
38
|
+
"</main>",
|
|
39
|
+
].join("");
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// よくあるエラーに、直し方のヒントを付ける(ルールベース。当てはまらなければ何も出さない)
|
|
2
|
+
// エラーメッセージの書き方はランタイムで違う(Bun と、V8 の Node / Deno)ので、両方を見る
|
|
3
|
+
import { errorCode } from "../errors.js";
|
|
4
|
+
// 利用者のコードが投げたエラーか(ランタイム内部のフレームは飛ばして、いちばん上を見る)
|
|
5
|
+
// スタックが分からなければ、利用者のものとして扱う
|
|
6
|
+
function thrownByUser(frames) {
|
|
7
|
+
const top = frames?.find((f) => f.kind !== "runtime");
|
|
8
|
+
return top === undefined || top.kind === "user";
|
|
9
|
+
}
|
|
10
|
+
// プロパティ名を取り出す
|
|
11
|
+
// V8: Cannot read properties of undefined (reading 'name')
|
|
12
|
+
// Bun: undefined is not an object (evaluating 'user.name')
|
|
13
|
+
function propertyOfNothing(message) {
|
|
14
|
+
const v8 = /^Cannot read properties of (?:undefined|null) \(reading '([^']+)'\)/.exec(message);
|
|
15
|
+
if (v8)
|
|
16
|
+
return v8[1];
|
|
17
|
+
const bun = /^(?:undefined|null) is not an object \(evaluating '.*\.([\w$]+)'\)/.exec(message);
|
|
18
|
+
return bun ? bun[1] : null;
|
|
19
|
+
}
|
|
20
|
+
const rules = [
|
|
21
|
+
// Senri 自身が投げるエラー
|
|
22
|
+
(err) => errorCode(err) === "next_twice"
|
|
23
|
+
? {
|
|
24
|
+
text: "1つのミドルウェアで `next()` を2回呼んでいます。`await next()` は1回だけにして、結果を変数に入れて使ってください。",
|
|
25
|
+
code: "const res = await next();",
|
|
26
|
+
}
|
|
27
|
+
: null,
|
|
28
|
+
(err) => errorCode(err) === "returned_function"
|
|
29
|
+
? { text: "関数を呼び出さずに返しています。`()` を付けて呼び出した結果を返してください。", code: "return getUser();" }
|
|
30
|
+
: null,
|
|
31
|
+
(err) => errorCode(err) === "returned_nothing"
|
|
32
|
+
? { text: "ハンドラーが値を返していません。`return` を忘れていないか確かめてください。", code: 'return "ok";' }
|
|
33
|
+
: null,
|
|
34
|
+
// undefined / null のプロパティを読んだ
|
|
35
|
+
(err) => {
|
|
36
|
+
if (!(err instanceof TypeError))
|
|
37
|
+
return null;
|
|
38
|
+
const prop = propertyOfNothing(err.message);
|
|
39
|
+
if (!prop)
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
text: `\`undefined\` か \`null\` の値から \`${prop}\` を読もうとしました。\`await\` のし忘れや、\`c.get()\` で値が入っていない場合がよくあります。なくてもよい値なら \`?.\` で読めます。`,
|
|
43
|
+
code: `value?.${prop}`,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
// 関数でないものを呼んだ
|
|
47
|
+
(err) => err instanceof TypeError && / is not a function/.test(err.message)
|
|
48
|
+
? { text: "呼び出そうとしたものが関数ではありません。名前のつづりや、import しているものを確かめてください。" }
|
|
49
|
+
: null,
|
|
50
|
+
// BigInt を JSON にしようとした
|
|
51
|
+
// V8: Do not know how to serialize a BigInt / Bun: JSON.stringify cannot serialize BigInt.
|
|
52
|
+
(err) => err instanceof TypeError && /serialize (?:a )?BigInt/.test(err.message)
|
|
53
|
+
? {
|
|
54
|
+
text: "`BigInt` は JSON にできません。文字列や数値に変換してから返してください。",
|
|
55
|
+
code: "String(value)",
|
|
56
|
+
}
|
|
57
|
+
: null,
|
|
58
|
+
// リクエストのボディが JSON として読めない(c.req.json())
|
|
59
|
+
// V8: ... in JSON at position 1 / ... is not valid JSON、Bun: JSON Parse error: ...
|
|
60
|
+
(err) => err instanceof SyntaxError && /JSON/.test(err.message)
|
|
61
|
+
? {
|
|
62
|
+
text: "JSON として読めませんでした。`c.req.json()` なら、送られてきたボディが壊れています。受け取った側の問題なので、400 で返すのがおすすめです。",
|
|
63
|
+
code: 'const body = await c.req.json().catch(() => {\n throw new HttpError(400, "JSON が正しくありません");\n});',
|
|
64
|
+
}
|
|
65
|
+
: null,
|
|
66
|
+
// 普通の Error を投げた(意図したエラーなら HttpError でステータスを決められる)
|
|
67
|
+
// - HttpError や、継承した独自のエラーは、利用者が意図して作ったものなので案内しない
|
|
68
|
+
// - ライブラリ(node_modules)が投げたものも案内しない(DB のエラーを 400 にするのは、たいてい間違い)
|
|
69
|
+
(err, frames) => Object.getPrototypeOf(err) === Error.prototype && thrownByUser(frames)
|
|
70
|
+
? {
|
|
71
|
+
text: "ハンドラーやミドルウェアで投げたエラーが、どこでもキャッチされませんでした。意図したエラーなら `HttpError` でステータスコードを指定できます。",
|
|
72
|
+
code: `throw new HttpError(400, ${JSON.stringify(err.message)})`,
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
];
|
|
76
|
+
export function hintFor(err, frames) {
|
|
77
|
+
if (!(err instanceof Error))
|
|
78
|
+
return null;
|
|
79
|
+
for (const rule of rules) {
|
|
80
|
+
const hint = rule(err, frames);
|
|
81
|
+
if (hint)
|
|
82
|
+
return hint;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function escapeHtml(value: unknown): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// HTML に埋め込む前に必ず通す(エラーページやダッシュボード用)
|
|
2
|
+
// リクエストのパスやエラーメッセージは攻撃者が自由に決められるので、
|
|
3
|
+
// そのまま埋め込むと <script> を仕込まれる(反射型 XSS)
|
|
4
|
+
const ENTITIES = {
|
|
5
|
+
"&": "&",
|
|
6
|
+
"<": "<",
|
|
7
|
+
">": ">",
|
|
8
|
+
'"': """,
|
|
9
|
+
"'": "'",
|
|
10
|
+
};
|
|
11
|
+
export function escapeHtml(value) {
|
|
12
|
+
// 1文字ずつ置き換えるので、& を先に処理しなくても二重にエスケープされない
|
|
13
|
+
return String(value).replace(/[&<>"']/g, (ch) => ENTITIES[ch]);
|
|
14
|
+
}
|