@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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -2
  3. package/dist/adapters/node/index.d.ts +14 -0
  4. package/dist/adapters/node/index.js +152 -0
  5. package/dist/adapters/serve/index.d.ts +28 -0
  6. package/dist/adapters/serve/index.js +137 -0
  7. package/dist/adapters/static/index.d.ts +9 -0
  8. package/dist/adapters/static/index.js +120 -0
  9. package/dist/src/app.d.ts +86 -0
  10. package/dist/src/app.js +325 -0
  11. package/dist/src/body.d.ts +3 -0
  12. package/dist/src/body.js +45 -0
  13. package/dist/src/compose.d.ts +22 -0
  14. package/dist/src/compose.js +80 -0
  15. package/dist/src/connection.d.ts +3 -0
  16. package/dist/src/connection.js +49 -0
  17. package/dist/src/context.d.ts +58 -0
  18. package/dist/src/context.js +235 -0
  19. package/dist/src/cookie.d.ts +18 -0
  20. package/dist/src/cookie.js +73 -0
  21. package/dist/src/dev/dashboard/access.d.ts +6 -0
  22. package/dist/src/dev/dashboard/access.js +46 -0
  23. package/dist/src/dev/dashboard/index.d.ts +27 -0
  24. package/dist/src/dev/dashboard/index.js +51 -0
  25. package/dist/src/dev/dashboard/links.d.ts +3 -0
  26. package/dist/src/dev/dashboard/links.js +8 -0
  27. package/dist/src/dev/dashboard/log.d.ts +20 -0
  28. package/dist/src/dev/dashboard/log.js +53 -0
  29. package/dist/src/dev/dashboard/mask.d.ts +4 -0
  30. package/dist/src/dev/dashboard/mask.js +43 -0
  31. package/dist/src/dev/dashboard/record.d.ts +37 -0
  32. package/dist/src/dev/dashboard/record.js +50 -0
  33. package/dist/src/dev/dashboard/routes.d.ts +7 -0
  34. package/dist/src/dev/dashboard/routes.js +121 -0
  35. package/dist/src/dev/dashboard/sse.d.ts +7 -0
  36. package/dist/src/dev/dashboard/sse.js +73 -0
  37. package/dist/src/dev/editor-url.d.ts +14 -0
  38. package/dist/src/dev/editor-url.js +61 -0
  39. package/dist/src/dev/editor.d.ts +6 -0
  40. package/dist/src/dev/editor.js +14 -0
  41. package/dist/src/dev/error-page.d.ts +3 -0
  42. package/dist/src/dev/error-page.js +69 -0
  43. package/dist/src/dev/explain.d.ts +2 -0
  44. package/dist/src/dev/explain.js +37 -0
  45. package/dist/src/dev/fallback.d.ts +3 -0
  46. package/dist/src/dev/fallback.js +40 -0
  47. package/dist/src/dev/hints.d.ts +6 -0
  48. package/dist/src/dev/hints.js +85 -0
  49. package/dist/src/dev/html.d.ts +1 -0
  50. package/dist/src/dev/html.js +14 -0
  51. package/dist/src/dev/index.d.ts +3 -0
  52. package/dist/src/dev/index.js +185 -0
  53. package/dist/src/dev/messages.d.ts +2 -0
  54. package/dist/src/dev/messages.js +6 -0
  55. package/dist/src/dev/not-found-page.d.ts +7 -0
  56. package/dist/src/dev/not-found-page.js +33 -0
  57. package/dist/src/dev/not-found.d.ts +3 -0
  58. package/dist/src/dev/not-found.js +15 -0
  59. package/dist/src/dev/page-data.d.ts +95 -0
  60. package/dist/src/dev/page-data.js +0 -0
  61. package/dist/src/dev/page.generated.d.ts +1 -0
  62. package/dist/src/dev/page.generated.js +3 -0
  63. package/dist/src/dev/pages.d.ts +4 -0
  64. package/dist/src/dev/pages.js +59 -0
  65. package/dist/src/dev/registry.d.ts +3 -0
  66. package/dist/src/dev/registry.js +7 -0
  67. package/dist/src/dev/render.d.ts +5 -0
  68. package/dist/src/dev/render.js +42 -0
  69. package/dist/src/dev/server-timing.d.ts +6 -0
  70. package/dist/src/dev/server-timing.js +45 -0
  71. package/dist/src/dev/source.d.ts +20 -0
  72. package/dist/src/dev/source.js +106 -0
  73. package/dist/src/dev/stack.d.ts +12 -0
  74. package/dist/src/dev/stack.js +93 -0
  75. package/dist/src/dev/suggest.d.ts +12 -0
  76. package/dist/src/dev/suggest.js +95 -0
  77. package/dist/src/dev/validation.d.ts +3 -0
  78. package/dist/src/dev/validation.js +47 -0
  79. package/dist/src/errors.d.ts +25 -0
  80. package/dist/src/errors.js +97 -0
  81. package/dist/src/group.d.ts +37 -0
  82. package/dist/src/group.js +75 -0
  83. package/dist/src/health.d.ts +9 -0
  84. package/dist/src/health.js +40 -0
  85. package/dist/src/index.d.ts +13 -0
  86. package/dist/src/index.js +12 -0
  87. package/dist/src/ip.d.ts +2 -0
  88. package/dist/src/ip.js +12 -0
  89. package/dist/src/logger.d.ts +18 -0
  90. package/dist/src/logger.js +110 -0
  91. package/dist/src/matcher.d.ts +16 -0
  92. package/dist/src/matcher.js +86 -0
  93. package/dist/src/middleware/body-limit.d.ts +6 -0
  94. package/dist/src/middleware/body-limit.js +47 -0
  95. package/dist/src/middleware/cors.d.ts +10 -0
  96. package/dist/src/middleware/cors.js +79 -0
  97. package/dist/src/middleware/response.d.ts +2 -0
  98. package/dist/src/middleware/response.js +28 -0
  99. package/dist/src/middleware/secure-headers.d.ts +19 -0
  100. package/dist/src/middleware/secure-headers.js +46 -0
  101. package/dist/src/router.d.ts +30 -0
  102. package/dist/src/router.js +177 -0
  103. package/dist/src/runtime.d.ts +5 -0
  104. package/dist/src/runtime.js +55 -0
  105. package/dist/src/validate.d.ts +46 -0
  106. package/dist/src/validate.js +55 -0
  107. package/package.json +90 -4
@@ -0,0 +1,235 @@
1
+ import { requestPathname, splitPath } from "./router.js";
2
+ import { isConnectionInfo } from "./connection.js";
3
+ import { readBody } from "./body.js";
4
+ import { senriError } from "./errors.js";
5
+ function isBodyValue(value) {
6
+ return (value instanceof Blob ||
7
+ value instanceof ArrayBuffer ||
8
+ ArrayBuffer.isView(value) ||
9
+ value instanceof ReadableStream ||
10
+ value instanceof FormData ||
11
+ value instanceof URLSearchParams);
12
+ }
13
+ // ボディの種類から決まる Content-Type。Bun は Headers に入れてくれないので、ランタイム差が出ないよう自分で入れる
14
+ // FormData は boundary 付きの値を Response が作るので、ここでは決めない
15
+ function contentTypeOf(value) {
16
+ if (value instanceof Blob)
17
+ return value.type || undefined;
18
+ if (value instanceof URLSearchParams)
19
+ return "application/x-www-form-urlencoded;charset=UTF-8";
20
+ return undefined;
21
+ }
22
+ // c.text() / c.json() の既定の Content-Type。Response は渡された Headers をコピーして使うので、共有してよい
23
+ // (オブジェクトリテラルを渡すより速い。毎回 Headers に変換しなくて済むため)
24
+ const TEXT_HEADERS = new Headers({ "content-type": "text/plain; charset=utf-8" });
25
+ const JSON_HEADERS = new Headers({ "content-type": "application/json" });
26
+ // init のオブジェクトは毎回作る。Bun.serve では、同じ init を使い回した Response を返すと
27
+ // HTTP のスループットが数分の1に落ちる(プロセス内で fetch を呼ぶだけでは差が出ない。bench/http.ts で確認)
28
+ function initOf(status, type, headers) {
29
+ return { status, headers: type ? { "content-type": type } : headers };
30
+ }
31
+ // スキーマで確かめた値(src/validate.ts が入れる)。c.valid() で読む
32
+ const validated = new WeakMap();
33
+ export function setValidated(c, target, value) {
34
+ let values = validated.get(c);
35
+ if (!values)
36
+ validated.set(c, (values = {}));
37
+ values[target] = value;
38
+ }
39
+ // app.fetch に渡された元のリクエストと2つ目の引数を読む(Context の外から。@senrijs/senri/ip で使う)
40
+ // Context のメソッドにしないのは、使わないアプリのバンドルに入れないため
41
+ export let connectionOf;
42
+ export class Context {
43
+ req;
44
+ #status = 200;
45
+ // c.header() で初めて作る(ほとんどのリクエストでは使わないので、毎回作らない)
46
+ #headers;
47
+ #segments;
48
+ // 取り出したままの pathname と、正規化したパス(c.path)
49
+ #pathname;
50
+ #path;
51
+ // c.set() で初めて作る
52
+ #vars;
53
+ // 一度読んだボディ(ミドルウェアとハンドラーの両方から読めるように覚えておく)
54
+ #body;
55
+ #query;
56
+ error = undefined;
57
+ // マッチしたルートのパターン("/users/:id")。見つからなければ undefined
58
+ // ミドルウェアより先にマッチするので、next() の前から読める(ログやダッシュボードの集計用)
59
+ route = undefined;
60
+ // パスパラメーター。ハンドラーでは第1引数でも受け取れる
61
+ params = {};
62
+ #timings;
63
+ // app.fetch に渡された元のリクエストと2つ目の引数(@senrijs/senri/ip の getIp で使う)
64
+ // Bun の requestIP は元のリクエストでしか答えないので、c.req が差し替えられても元のほうで聞く
65
+ #original;
66
+ #info;
67
+ static {
68
+ connectionOf = (c) => ({ req: c.#original, info: c.#info });
69
+ }
70
+ #ctx;
71
+ constructor(req, info, ctx) {
72
+ this.req = req;
73
+ this.#original = req;
74
+ this.#info = info;
75
+ this.#ctx = ctx;
76
+ }
77
+ // Cloudflare Workers の env(KV や環境変数などのバインディング)。型は createApp<{ Bindings: ... }>() で決める
78
+ // Bun の server や Deno の info(接続元を知るためのもの)は env ではないので、そのときは undefined
79
+ get env() {
80
+ return (isConnectionInfo(this.#info) ? undefined : this.#info);
81
+ }
82
+ // Cloudflare Workers の ctx(waitUntil など)。Workers 以外では渡されないので、読むとエラーにする
83
+ get executionCtx() {
84
+ if (!this.#ctx) {
85
+ throw senriError("no_execution_ctx", "c.executionCtx is only available on Cloudflare Workers");
86
+ }
87
+ return this.#ctx;
88
+ }
89
+ // パスをデコードして "/" で分けたもの。ルーターもミドルウェアもこれを見る
90
+ // 初めて読んだときに1回だけ作る。エンコードが不正なら HttpError(400) を投げる
91
+ get segments() {
92
+ this.#segments ??= splitPath(this.#rawPathname());
93
+ return this.#segments;
94
+ }
95
+ // req.url から取り出したままの pathname(1回だけ取り出す)。おかしなリクエストなら HttpError(400) を投げる
96
+ #rawPathname() {
97
+ this.#pathname ??= requestPathname(this.req);
98
+ return this.#pathname;
99
+ }
100
+ // 正規化・デコード済みのパス("//admin/" → "/admin"、"/%61dmin" → "/admin")
101
+ // パスで判定するミドルウェアは、req.url ではなくこちらを使うこと
102
+ get path() {
103
+ if (this.#path === undefined) {
104
+ const p = this.#rawPathname();
105
+ // エンコードも、連続・末尾のスラッシュもなければ、取り出したままのものと同じ(分割もつなぎ直しもしない)
106
+ // ("." のセグメントは requestPathname が new URL() で解決済みなので、ここには来ない)
107
+ const clean = !p.includes("%") && !p.includes("//") && (p.length === 1 || !p.endsWith("/"));
108
+ this.#path = clean ? p : "/" + this.segments.join("/");
109
+ }
110
+ return this.#path;
111
+ }
112
+ // 開発モードのときだけ、ミドルウェアとハンドラーの通過時間が入る(外側から順)
113
+ // 自分より内側の層は、next() が終わったあとに読める
114
+ get timings() {
115
+ return (this.#timings ??= []);
116
+ }
117
+ // ルートに渡したスキーマで確かめた値。c.valid("body") / c.valid("query")
118
+ // (スキーマを渡していないものは型エラー。JS から呼んだときは、分かりやすいエラーにする)
119
+ valid(target) {
120
+ const values = validated.get(this);
121
+ if (!values || !(target in values)) {
122
+ throw senriError("no_valid", `c.valid("${target}"): no schema for ${target}`);
123
+ }
124
+ return values[target];
125
+ }
126
+ // ミドルウェアからハンドラーへ値を渡す(ログインしたユーザーなど)
127
+ set(key, value) {
128
+ (this.#vars ??= new Map()).set(key, value);
129
+ return this;
130
+ }
131
+ // set していなければ undefined(ミドルウェアが動かなかったときなど)
132
+ get(key) {
133
+ return this.#vars?.get(key);
134
+ }
135
+ // --- クエリ ---
136
+ #searchParams() {
137
+ this.#query ??= new URL(this.req.url).searchParams;
138
+ return this.#query;
139
+ }
140
+ query(name) {
141
+ const params = this.#searchParams();
142
+ if (name !== undefined) {
143
+ const all = params.getAll(name);
144
+ return all.length > 0 ? all[all.length - 1] : undefined;
145
+ }
146
+ return Object.fromEntries(params);
147
+ }
148
+ // 同じ名前のものを全部(?tag=a&tag=b → ["a", "b"])
149
+ queries(name) {
150
+ return this.#searchParams().getAll(name);
151
+ }
152
+ // --- ボディ ---
153
+ // Content-Type に合わせて読む(JSON / フォーム / テキスト / それ以外は ArrayBuffer。src/body.ts)
154
+ // 一度読んだら覚えておくので、何度呼んでもよい
155
+ body() {
156
+ this.#body ??= readBody(this.req);
157
+ return this.#body;
158
+ }
159
+ status(code) {
160
+ this.#status = code;
161
+ return this;
162
+ }
163
+ // ここで設定したヘッダーは、最後に finalize() で1回だけレスポンスに足される
164
+ // なので、Response を直接返したときも、エラーのときも、next() のあとに設定しても効く
165
+ header(name, value) {
166
+ // Set-Cookie は1つのレスポンスに何個も付けるものなので、上書きせずに足す
167
+ const headers = (this.#headers ??= new Headers());
168
+ if (name.toLowerCase() === "set-cookie") {
169
+ headers.append(name, value);
170
+ }
171
+ else {
172
+ headers.set(name, value);
173
+ }
174
+ return this;
175
+ }
176
+ // json() と text() は Content-Type と status だけ決める(ほかのヘッダーは finalize() で足す)
177
+ // 共有の #headers を書き換えないので、json() のあとに text() しても Content-Type が混ざらない
178
+ json(data, status) {
179
+ const init = initOf(status ?? this.#status, this.#headers?.get("content-type"), JSON_HEADERS);
180
+ return new Response(JSON.stringify(data), init);
181
+ }
182
+ text(body, status) {
183
+ // 明示しないと、Bun は Headers に Content-Type を入れない(Node / Deno は入れる)のでランタイムで差が出る
184
+ const init = initOf(status ?? this.#status, this.#headers?.get("content-type"), TEXT_HEADERS);
185
+ return new Response(body, init);
186
+ }
187
+ // c.header() で設定したヘッダーを res に足す。app が最後に1回だけ呼ぶ
188
+ // - c.header() の値が優先。ただし Content-Type だけはレスポンス自身のものが優先
189
+ // - Set-Cookie は足す
190
+ // - c.status() は使わない(Response を直接返したなら、その status を尊重する)
191
+ finalize(res) {
192
+ const own = this.#headers;
193
+ // 何もなければ作り直さない。101(WebSocket のアップグレード)なども作り直すと壊れる
194
+ if (own === undefined || res.status < 200)
195
+ return res;
196
+ let hasHeaders = false;
197
+ own.forEach(() => (hasHeaders = true));
198
+ if (!hasHeaders)
199
+ return res;
200
+ // fetch() や Response.redirect() の Response はヘッダーを変更できないので、新しく作る
201
+ const headers = new Headers(res.headers);
202
+ own.forEach((value, key) => {
203
+ if (key === "set-cookie")
204
+ return;
205
+ if (key === "content-type" && headers.has(key))
206
+ return;
207
+ headers.set(key, value);
208
+ });
209
+ for (const cookie of own.getSetCookie()) {
210
+ headers.append("set-cookie", cookie);
211
+ }
212
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
213
+ }
214
+ toResponse(value) {
215
+ if (value instanceof Response) {
216
+ return value;
217
+ }
218
+ if (typeof value === "string") {
219
+ return this.text(value);
220
+ }
221
+ if (isBodyValue(value)) {
222
+ const contentType = this.#headers?.get("content-type") ?? contentTypeOf(value);
223
+ // headers: undefined は渡さない(exactOptionalPropertyTypes の利用者の型チェックで、エラーになるので)
224
+ return new Response(value, contentType ? { status: this.#status, headers: { "content-type": contentType } } : { status: this.#status });
225
+ }
226
+ // 型では防いでいるが、JS から使ったときや as で無理やり通したときのために実行時にも確かめる
227
+ if (typeof value === "function") {
228
+ throw senriError("returned_function", new TypeError("Handler returned a function"));
229
+ }
230
+ if (value === undefined || value === null) {
231
+ throw senriError("returned_nothing", new TypeError("Handler returned undefined or null"));
232
+ }
233
+ return this.json(value);
234
+ }
235
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from "./context.ts";
2
+ export type CookieOptions = {
3
+ maxAge?: number;
4
+ expires?: Date;
5
+ domain?: string;
6
+ path?: string;
7
+ httpOnly?: boolean;
8
+ secure?: boolean;
9
+ sameSite?: "Strict" | "Lax" | "None";
10
+ partitioned?: boolean;
11
+ };
12
+ export declare function parseCookies(header: string | null): Record<string, string>;
13
+ export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
14
+ export declare function deletionCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): string;
15
+ export declare function getCookie(c: Context): Record<string, string>;
16
+ export declare function getCookie(c: Context, name: string): string | undefined;
17
+ export declare function setCookie(c: Context, name: string, value: string, options?: CookieOptions): void;
18
+ export declare function deleteCookie(c: Context, name: string, options?: Pick<CookieOptions, "path" | "domain">): void;
@@ -0,0 +1,73 @@
1
+ import { senriError } from "./errors.js";
2
+ // Cookie の名前に使える文字(RFC 6265 の token)
3
+ const NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
4
+ // "Cookie: a=1; b=2" を { a: "1", b: "2" } に。値はデコードする(できなければそのまま)
5
+ export function parseCookies(header) {
6
+ // プロトタイプのないオブジェクト(ふつうの {} だと "constructor" などの名前が継承したものと混ざる)
7
+ const out = Object.create(null);
8
+ if (!header)
9
+ return out;
10
+ for (const part of header.split(";")) {
11
+ const eq = part.indexOf("=");
12
+ if (eq === -1)
13
+ continue;
14
+ const name = part.slice(0, eq).trim();
15
+ if (!name || name in out)
16
+ continue;
17
+ let value = part.slice(eq + 1).trim();
18
+ if (value.startsWith('"') && value.endsWith('"'))
19
+ value = value.slice(1, -1);
20
+ try {
21
+ out[name] = decodeURIComponent(value);
22
+ }
23
+ catch {
24
+ out[name] = value;
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ export function serializeCookie(name, value, options = {}) {
30
+ if (!NAME.test(name)) {
31
+ throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
32
+ }
33
+ const parts = [`${name}=${encodeURIComponent(value)}`];
34
+ if (options.maxAge !== undefined)
35
+ parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
36
+ if (options.expires)
37
+ parts.push(`Expires=${options.expires.toUTCString()}`);
38
+ if (options.domain)
39
+ parts.push(`Domain=${options.domain}`);
40
+ parts.push(`Path=${options.path ?? "/"}`);
41
+ if (options.httpOnly ?? true)
42
+ parts.push("HttpOnly");
43
+ // __Secure- / __Host- で始まる名前は、Secure がないとブラウザに捨てられる
44
+ if (options.secure || name.startsWith("__Secure-") || name.startsWith("__Host-"))
45
+ parts.push("Secure");
46
+ parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
47
+ if (options.partitioned)
48
+ parts.push("Partitioned");
49
+ return parts.join("; ");
50
+ }
51
+ // 消すための Set-Cookie。Path と Domain は、付けたときと同じものを指定する
52
+ export function deletionCookie(name, options = {}) {
53
+ if (!NAME.test(name)) {
54
+ throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
55
+ }
56
+ const parts = [`${name}=`, "Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT"];
57
+ if (options.domain)
58
+ parts.push(`Domain=${options.domain}`);
59
+ parts.push(`Path=${options.path ?? "/"}`);
60
+ return parts.join("; ");
61
+ }
62
+ export function getCookie(c, name) {
63
+ const all = parseCookies(c.req.headers.get("cookie"));
64
+ return name === undefined ? { ...all } : all[name];
65
+ }
66
+ // 既定は Path=/・HttpOnly・SameSite=Lax。最後に1回だけレスポンスに足される(c.header と同じ)
67
+ export function setCookie(c, name, value, options) {
68
+ c.header("set-cookie", serializeCookie(name, value, options));
69
+ }
70
+ // Path と Domain は、付けたときと同じものを指定する
71
+ export function deleteCookie(c, name, options) {
72
+ c.header("set-cookie", deletionCookie(name, options));
73
+ }
@@ -0,0 +1,6 @@
1
+ import { remoteAddress } from "../../connection.ts";
2
+ export { remoteAddress };
3
+ export declare function isLoopback(address: string | null): boolean;
4
+ export declare function isLocalHost(host: string | null): boolean;
5
+ export declare function canOpenDashboard(req: Request, info: unknown): boolean;
6
+ export declare function sameOrigin(req: Request): boolean;
@@ -0,0 +1,46 @@
1
+ // ダッシュボード(/_senri)を開いてよいリクエストかを決める
2
+ //
3
+ // ダッシュボードには、リクエストのヘッダーやエラーの中身(ソースコード)が出るので、
4
+ // 同じ PC(ループバック)からのアクセスだけに絞る。接続元のアドレスは src/connection.ts で取り出す
5
+ // (取り出せなければ「接続元不明」として、ダッシュボードだけ拒否し、ほかは普通に動く)
6
+ import { remoteAddress } from "../../connection.js";
7
+ export { remoteAddress };
8
+ // 127.0.0.0/8 と ::1(IPv4 を IPv6 の形で書いた ::ffff:127.x.x.x も)
9
+ export function isLoopback(address) {
10
+ if (!address)
11
+ return false;
12
+ const a = address.toLowerCase().replace(/^\[|\]$/g, "");
13
+ if (a === "::1" || a === "0:0:0:0:0:0:0:1")
14
+ return true;
15
+ const v4 = a.startsWith("::ffff:") ? a.slice("::ffff:".length) : a;
16
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4);
17
+ }
18
+ // Host ヘッダーが localhost 系か(DNS リバインディング対策)
19
+ // 悪意のあるサイトが自分のドメインを 127.0.0.1 に向けると、ブラウザは接続元がループバックのまま
20
+ // Host: evil.example でアクセスしてくる。接続元だけでなく Host も見ることで、これを防ぐ
21
+ export function isLocalHost(host) {
22
+ if (!host)
23
+ return false;
24
+ const h = host.toLowerCase();
25
+ if (h === "[::1]" || h.startsWith("[::1]:"))
26
+ return true;
27
+ const name = h.replace(/:\d+$/, "");
28
+ return name === "localhost" || name.endsWith(".localhost") || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
29
+ }
30
+ export function canOpenDashboard(req, info) {
31
+ return isLoopback(remoteAddress(req, info)) && isLocalHost(req.headers.get("host"));
32
+ }
33
+ // 状態を変えるリクエスト(クリアなど)は、ダッシュボード自身のページから来たものだけ受け付ける
34
+ // (ほかのサイトのページから、こっそり POST させられないように)
35
+ export function sameOrigin(req) {
36
+ const origin = req.headers.get("origin");
37
+ const host = req.headers.get("host");
38
+ if (!origin || !host)
39
+ return false;
40
+ try {
41
+ return new URL(origin).host === host;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,27 @@
1
+ import type { Context } from "../../context.ts";
2
+ import { RequestLog } from "./log.ts";
3
+ import { type DashboardRecord } from "./record.ts";
4
+ export type DashboardSource = {
5
+ routes(): {
6
+ method: string;
7
+ path: string;
8
+ }[];
9
+ middlewares(): {
10
+ name: string;
11
+ path: string | null;
12
+ anonymous: boolean;
13
+ }[];
14
+ };
15
+ export declare function createDashboard(source: DashboardSource): {
16
+ log: RequestLog<DashboardRecord>;
17
+ source: DashboardSource;
18
+ canOpen(req: Request, info: unknown): boolean;
19
+ handle(req: Request, segments: readonly string[]): Promise<Response>;
20
+ begin(c: Context, info: unknown): string;
21
+ idOf(c: Context): string | undefined;
22
+ record(c: Context, res: Response, time: number, layerNames: {
23
+ name: string;
24
+ order: number | null;
25
+ }[]): Promise<void>;
26
+ };
27
+ export type Dashboard = ReturnType<typeof createDashboard>;
@@ -0,0 +1,51 @@
1
+ import { canOpenDashboard } from "./access.js";
2
+ import { setDashboardLink } from "./links.js";
3
+ import { RequestLog } from "./log.js";
4
+ import { buildRecord } from "./record.js";
5
+ import { handleDashboard } from "./routes.js";
6
+ import { eventStream } from "./sse.js";
7
+ export function createDashboard(source) {
8
+ const log = new RequestLog();
9
+ // SSE(/_senri/api/events)。記録が増えるたびに、開いているダッシュボードへ知らせる
10
+ const events = (req) => eventStream(log, req);
11
+ // リクエストごとの id。エラーページの「/_senri で詳しく見る」から、そのリクエストを開くのに使う
12
+ const ids = new WeakMap();
13
+ let nextId = 1;
14
+ const dashboard = {
15
+ log,
16
+ source,
17
+ // localhost から開いたときだけ(src/dev/dashboard/access.ts)
18
+ canOpen(req, info) {
19
+ return canOpenDashboard(req, info);
20
+ },
21
+ // segments: パスの "_senri" より後ろ
22
+ handle(req, segments) {
23
+ return handleDashboard(dashboard, req, segments, events);
24
+ },
25
+ // リクエストの最初に呼ぶ。id を振る(/_senri へのリクエストには呼ばないので、記録されない)
26
+ // info: app.fetch の2つ目の引数。localhost からのリクエストなら、エラーページなどにリンクを出す
27
+ begin(c, info) {
28
+ const id = String(nextId++);
29
+ ids.set(c, id);
30
+ if (canOpenDashboard(c.req, info))
31
+ setDashboardLink(c, `/_senri#requests/${id}`);
32
+ return id;
33
+ },
34
+ idOf(c) {
35
+ return ids.get(c);
36
+ },
37
+ // レスポンスを返す直前に呼ぶ。記録に失敗しても、リクエストには影響させない
38
+ async record(c, res, time, layerNames) {
39
+ const id = ids.get(c);
40
+ if (id === undefined)
41
+ return;
42
+ try {
43
+ log.add(await buildRecord({ id, time, c, res, layerNames }));
44
+ }
45
+ catch {
46
+ // 記録はおまけなので、失敗しても黙って捨てる
47
+ }
48
+ },
49
+ };
50
+ return dashboard;
51
+ }
@@ -0,0 +1,3 @@
1
+ import type { Context } from "../../context.ts";
2
+ export declare function setDashboardLink(c: Context, url: string): void;
3
+ export declare function dashboardLinkOf(c: Context): string | null;
@@ -0,0 +1,8 @@
1
+ const links = new WeakMap();
2
+ export function setDashboardLink(c, url) {
3
+ links.set(c, url);
4
+ }
5
+ // 開けないとき(localhost 以外、接続元不明、本番)は null
6
+ export function dashboardLinkOf(c) {
7
+ return links.get(c) ?? null;
8
+ }
@@ -0,0 +1,20 @@
1
+ export type LogEvent<T> = {
2
+ type: "request";
3
+ item: T;
4
+ } | {
5
+ type: "clear";
6
+ };
7
+ export declare class RequestLog<T extends {
8
+ id: string;
9
+ }> {
10
+ #private;
11
+ readonly max: number;
12
+ constructor(max?: number);
13
+ get size(): number;
14
+ get listenerCount(): number;
15
+ add(item: T): void;
16
+ list(): T[];
17
+ get(id: string): T | undefined;
18
+ clear(): void;
19
+ subscribe(listener: (event: LogEvent<T>) => void): () => void;
20
+ }
@@ -0,0 +1,53 @@
1
+ // ダッシュボードに出すリクエストの記録(メモリ上。再起動で消える)
2
+ // 直近の max 件だけを持ち、それより古いものから捨てる。追加とクリアは購読者(SSE)に知らせる
3
+ export class RequestLog {
4
+ max;
5
+ // 古い順。新しいものは末尾に足す
6
+ #items = [];
7
+ #listeners = new Set();
8
+ constructor(max = 200) {
9
+ this.max = max;
10
+ }
11
+ get size() {
12
+ return this.#items.length;
13
+ }
14
+ get listenerCount() {
15
+ return this.#listeners.size;
16
+ }
17
+ add(item) {
18
+ this.#items.push(item);
19
+ // 上限を超えた分を、古いほう(先頭)から捨てる
20
+ if (this.#items.length > this.max) {
21
+ this.#items.splice(0, this.#items.length - this.max);
22
+ }
23
+ this.#emit({ type: "request", item });
24
+ }
25
+ // 新しい順
26
+ list() {
27
+ return [...this.#items].reverse();
28
+ }
29
+ get(id) {
30
+ return this.#items.find((item) => item.id === id);
31
+ }
32
+ clear() {
33
+ this.#items = [];
34
+ this.#emit({ type: "clear" });
35
+ }
36
+ // 戻り値の関数を呼ぶと購読をやめる(SSE の接続が切れたときに呼ぶ)
37
+ subscribe(listener) {
38
+ this.#listeners.add(listener);
39
+ return () => {
40
+ this.#listeners.delete(listener);
41
+ };
42
+ }
43
+ #emit(event) {
44
+ for (const listener of this.#listeners) {
45
+ try {
46
+ listener(event);
47
+ }
48
+ catch {
49
+ // 1つの購読者が壊れていても、記録やほかの購読者は止めない
50
+ }
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,4 @@
1
+ export declare const MASK = "***";
2
+ export declare function isSecretHeader(name: string): boolean;
3
+ export declare function maskQuery(search: string): string;
4
+ export declare function maskHeaders(headers: Headers): [string, string][];
@@ -0,0 +1,43 @@
1
+ // ダッシュボードに出すヘッダーのうち、機密になりうるものを *** に伏せる
2
+ // (画面を人に見せたり、スクリーンショットを貼ったりしても漏れないように)
3
+ // 名前で決まっているもの
4
+ const SECRET_NAMES = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]);
5
+ // 名前にこれらを含むもの(x-api-key、x-auth-token、x-session-id など)
6
+ const SECRET_PATTERN = /token|secret|passw(or)?d|api[-_]?key|session|signature|credential/i;
7
+ export const MASK = "***";
8
+ export function isSecretHeader(name) {
9
+ const n = name.toLowerCase();
10
+ return SECRET_NAMES.has(n) || SECRET_PATTERN.test(n);
11
+ }
12
+ // クエリ文字列("?token=abc&page=2")のうち、名前が機密っぽいパラメーターの値を *** にする
13
+ // (ヘッダーと同じ名前のルール。?token=... や ?api_key=... は、コールバックの URL などによく付く)
14
+ // URL を組み立て直すとエンコードが変わってしまうので、"名前=値" の値の部分だけを置き換える
15
+ export function maskQuery(search) {
16
+ if (search === "" || search === "?")
17
+ return search;
18
+ const pairs = search.replace(/^\?/, "").split("&");
19
+ const masked = pairs.map((pair) => {
20
+ const eq = pair.indexOf("=");
21
+ // 値のないもの("?debug")は伏せるものがない
22
+ if (eq === -1)
23
+ return pair;
24
+ const rawName = pair.slice(0, eq);
25
+ let name = rawName;
26
+ try {
27
+ name = decodeURIComponent(rawName.replaceAll("+", " "));
28
+ }
29
+ catch {
30
+ // デコードできない名前は、そのままの形で判定する
31
+ }
32
+ return isSecretHeader(name) ? `${rawName}=${MASK}` : pair;
33
+ });
34
+ return `?${masked.join("&")}`;
35
+ }
36
+ // [名前, 値] の配列(名前の順)。Set-Cookie は1つずつ並ぶので、何個付いたかは分かる
37
+ export function maskHeaders(headers) {
38
+ const out = [];
39
+ for (const [name, value] of headers) {
40
+ out.push([name, isSecretHeader(name) ? MASK : value]);
41
+ }
42
+ return out.sort((a, b) => a[0].localeCompare(b[0]));
43
+ }
@@ -0,0 +1,37 @@
1
+ import type { Context } from "../../context.ts";
2
+ import type { ErrorPageData } from "../page-data.ts";
3
+ export type LayerState = "ok" | "stopped" | "failed" | "skipped";
4
+ export type RecordLayer = {
5
+ name: string;
6
+ order: number | null;
7
+ depth: number;
8
+ start: number;
9
+ total: number;
10
+ self: number;
11
+ state: LayerState;
12
+ };
13
+ export type DashboardRecord = {
14
+ id: string;
15
+ time: number;
16
+ method: string;
17
+ path: string;
18
+ query: string;
19
+ route: string | null;
20
+ params: Record<string, string>;
21
+ status: number;
22
+ durationMs: number;
23
+ layers: RecordLayer[];
24
+ requestHeaders: [string, string][];
25
+ responseHeaders: [string, string][];
26
+ error: ErrorPageData | null;
27
+ };
28
+ export declare function buildRecord(input: {
29
+ id: string;
30
+ time: number;
31
+ c: Context;
32
+ res: Response;
33
+ layerNames: {
34
+ name: string;
35
+ order: number | null;
36
+ }[];
37
+ }): Promise<DashboardRecord>;