@senrijs/senri 0.1.0 → 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/README.md +62 -16
- package/dist/src/app.d.ts +41 -4
- package/dist/src/app.js +67 -117
- package/dist/src/compose.d.ts +5 -5
- package/dist/src/compose.js +15 -30
- package/dist/src/context.d.ts +4 -6
- package/dist/src/context.js +15 -34
- package/dist/src/cookie.d.ts +5 -0
- package/dist/src/cookie.js +15 -3
- package/dist/src/dev/explain.d.ts +2 -0
- package/dist/src/dev/explain.js +37 -0
- package/dist/src/dev/hints.js +6 -3
- package/dist/src/dev/index.d.ts +3 -6
- package/dist/src/dev/index.js +184 -10
- package/dist/src/dev/not-found.d.ts +3 -0
- package/dist/src/dev/not-found.js +15 -0
- package/dist/src/dev/registry.d.ts +3 -0
- package/dist/src/dev/registry.js +7 -0
- package/dist/src/dev/validation.d.ts +3 -0
- package/dist/src/dev/validation.js +47 -0
- package/dist/src/errors.d.ts +7 -5
- package/dist/src/errors.js +16 -55
- package/dist/src/group.d.ts +0 -1
- package/dist/src/group.js +8 -16
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +3 -1
- package/dist/src/ip.d.ts +2 -0
- package/dist/src/ip.js +12 -0
- package/dist/src/router.js +5 -5
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/validate.d.ts +1 -2
- package/dist/src/validate.js +12 -44
- package/package.json +19 -3
- package/dist/src/dev/runtime.d.ts +0 -3
- package/dist/src/dev/runtime.js +0 -45
package/dist/src/context.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Timing } from "./compose.ts";
|
|
2
|
-
import { type CookieOptions } from "./cookie.ts";
|
|
3
2
|
export type BodyValue = Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer> | ReadableStream | FormData | URLSearchParams;
|
|
4
3
|
export type JsonObject = object & {
|
|
5
4
|
call?: never;
|
|
@@ -26,6 +25,10 @@ export type Handler<P = Record<string, string>, V extends object = any, I extend
|
|
|
26
25
|
export type Next = () => Promise<Response>;
|
|
27
26
|
export type Middleware<V extends object = any> = (c: Context<V>, next: Next) => HandlerResult | void | Promise<HandlerResult | void>;
|
|
28
27
|
export declare function setValidated(c: Context, target: string, value: unknown): void;
|
|
28
|
+
export declare let connectionOf: (c: Context) => {
|
|
29
|
+
req: Request;
|
|
30
|
+
info: unknown;
|
|
31
|
+
};
|
|
29
32
|
export declare class Context<V extends object = any, I extends object = {}> {
|
|
30
33
|
#private;
|
|
31
34
|
req: Request;
|
|
@@ -35,7 +38,6 @@ export declare class Context<V extends object = any, I extends object = {}> {
|
|
|
35
38
|
constructor(req: Request, info?: unknown, ctx?: ExecutionContext);
|
|
36
39
|
get env(): BindingsOf<V>;
|
|
37
40
|
get executionCtx(): ExecutionContext;
|
|
38
|
-
get ip(): string | null;
|
|
39
41
|
get segments(): readonly string[];
|
|
40
42
|
get path(): string;
|
|
41
43
|
get timings(): Timing[];
|
|
@@ -46,10 +48,6 @@ export declare class Context<V extends object = any, I extends object = {}> {
|
|
|
46
48
|
query(name: string): string | undefined;
|
|
47
49
|
queries(name: string): string[];
|
|
48
50
|
body<T = unknown>(): Promise<T>;
|
|
49
|
-
cookie(): Record<string, string>;
|
|
50
|
-
cookie(name: string): string | undefined;
|
|
51
|
-
setCookie(name: string, value: string, options?: CookieOptions): this;
|
|
52
|
-
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): this;
|
|
53
51
|
status(code: number): this;
|
|
54
52
|
header(name: string, value: string): this;
|
|
55
53
|
json(data: unknown, status?: number): Response;
|
package/dist/src/context.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { requestPathname, splitPath } from "./router.js";
|
|
2
|
-
import {
|
|
2
|
+
import { isConnectionInfo } from "./connection.js";
|
|
3
3
|
import { readBody } from "./body.js";
|
|
4
|
-
import {
|
|
4
|
+
import { senriError } from "./errors.js";
|
|
5
5
|
function isBodyValue(value) {
|
|
6
6
|
return (value instanceof Blob ||
|
|
7
7
|
value instanceof ArrayBuffer ||
|
|
@@ -36,6 +36,9 @@ export function setValidated(c, target, value) {
|
|
|
36
36
|
validated.set(c, (values = {}));
|
|
37
37
|
values[target] = value;
|
|
38
38
|
}
|
|
39
|
+
// app.fetch に渡された元のリクエストと2つ目の引数を読む(Context の外から。@senrijs/senri/ip で使う)
|
|
40
|
+
// Context のメソッドにしないのは、使わないアプリのバンドルに入れないため
|
|
41
|
+
export let connectionOf;
|
|
39
42
|
export class Context {
|
|
40
43
|
req;
|
|
41
44
|
#status = 200;
|
|
@@ -50,7 +53,6 @@ export class Context {
|
|
|
50
53
|
// 一度読んだボディ(ミドルウェアとハンドラーの両方から読めるように覚えておく)
|
|
51
54
|
#body;
|
|
52
55
|
#query;
|
|
53
|
-
#cookies;
|
|
54
56
|
error = undefined;
|
|
55
57
|
// マッチしたルートのパターン("/users/:id")。見つからなければ undefined
|
|
56
58
|
// ミドルウェアより先にマッチするので、next() の前から読める(ログやダッシュボードの集計用)
|
|
@@ -58,11 +60,13 @@ export class Context {
|
|
|
58
60
|
// パスパラメーター。ハンドラーでは第1引数でも受け取れる
|
|
59
61
|
params = {};
|
|
60
62
|
#timings;
|
|
61
|
-
// app.fetch に渡された元のリクエストと2つ目の引数(
|
|
63
|
+
// app.fetch に渡された元のリクエストと2つ目の引数(@senrijs/senri/ip の getIp で使う)
|
|
62
64
|
// Bun の requestIP は元のリクエストでしか答えないので、c.req が差し替えられても元のほうで聞く
|
|
63
65
|
#original;
|
|
64
66
|
#info;
|
|
65
|
-
|
|
67
|
+
static {
|
|
68
|
+
connectionOf = (c) => ({ req: c.#original, info: c.#info });
|
|
69
|
+
}
|
|
66
70
|
#ctx;
|
|
67
71
|
constructor(req, info, ctx) {
|
|
68
72
|
this.req = req;
|
|
@@ -78,17 +82,10 @@ export class Context {
|
|
|
78
82
|
// Cloudflare Workers の ctx(waitUntil など)。Workers 以外では渡されないので、読むとエラーにする
|
|
79
83
|
get executionCtx() {
|
|
80
84
|
if (!this.#ctx) {
|
|
81
|
-
throw
|
|
85
|
+
throw senriError("no_execution_ctx", "c.executionCtx is only available on Cloudflare Workers");
|
|
82
86
|
}
|
|
83
87
|
return this.#ctx;
|
|
84
88
|
}
|
|
85
|
-
// 接続元の IP アドレス。分からなければ null(Cloudflare Workers や、app.fetch を直接呼んだときなど)
|
|
86
|
-
// X-Forwarded-For などのヘッダーは偽装できるので読まない。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読む
|
|
87
|
-
get ip() {
|
|
88
|
-
if (this.#ip === undefined)
|
|
89
|
-
this.#ip = clientIp(this.#original, this.#info);
|
|
90
|
-
return this.#ip;
|
|
91
|
-
}
|
|
92
89
|
// パスをデコードして "/" で分けたもの。ルーターもミドルウェアもこれを見る
|
|
93
90
|
// 初めて読んだときに1回だけ作る。エンコードが不正なら HttpError(400) を投げる
|
|
94
91
|
get segments() {
|
|
@@ -122,7 +119,7 @@ export class Context {
|
|
|
122
119
|
valid(target) {
|
|
123
120
|
const values = validated.get(this);
|
|
124
121
|
if (!values || !(target in values)) {
|
|
125
|
-
throw
|
|
122
|
+
throw senriError("no_valid", `c.valid("${target}"): no schema for ${target}`);
|
|
126
123
|
}
|
|
127
124
|
return values[target];
|
|
128
125
|
}
|
|
@@ -159,20 +156,6 @@ export class Context {
|
|
|
159
156
|
this.#body ??= readBody(this.req);
|
|
160
157
|
return this.#body;
|
|
161
158
|
}
|
|
162
|
-
cookie(name) {
|
|
163
|
-
this.#cookies ??= parseCookies(this.req.headers.get("cookie"));
|
|
164
|
-
return name === undefined ? { ...this.#cookies } : this.#cookies[name];
|
|
165
|
-
}
|
|
166
|
-
// 既定は Path=/・HttpOnly・SameSite=Lax(src/cookie.ts)
|
|
167
|
-
setCookie(name, value, options) {
|
|
168
|
-
this.header("set-cookie", serializeCookie(name, value, options));
|
|
169
|
-
return this;
|
|
170
|
-
}
|
|
171
|
-
// Path と Domain は、付けたときと同じものを指定する
|
|
172
|
-
deleteCookie(name, options) {
|
|
173
|
-
this.header("set-cookie", deletionCookie(name, options));
|
|
174
|
-
return this;
|
|
175
|
-
}
|
|
176
159
|
status(code) {
|
|
177
160
|
this.#status = code;
|
|
178
161
|
return this;
|
|
@@ -237,17 +220,15 @@ export class Context {
|
|
|
237
220
|
}
|
|
238
221
|
if (isBodyValue(value)) {
|
|
239
222
|
const contentType = this.#headers?.get("content-type") ?? contentTypeOf(value);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
headers: contentType ? { "content-type": contentType } : undefined,
|
|
243
|
-
});
|
|
223
|
+
// headers: undefined は渡さない(exactOptionalPropertyTypes の利用者の型チェックで、エラーになるので)
|
|
224
|
+
return new Response(value, contentType ? { status: this.#status, headers: { "content-type": contentType } } : { status: this.#status });
|
|
244
225
|
}
|
|
245
226
|
// 型では防いでいるが、JS から使ったときや as で無理やり通したときのために実行時にも確かめる
|
|
246
227
|
if (typeof value === "function") {
|
|
247
|
-
throw new TypeError("
|
|
228
|
+
throw senriError("returned_function", new TypeError("Handler returned a function"));
|
|
248
229
|
}
|
|
249
230
|
if (value === undefined || value === null) {
|
|
250
|
-
throw new TypeError("
|
|
231
|
+
throw senriError("returned_nothing", new TypeError("Handler returned undefined or null"));
|
|
251
232
|
}
|
|
252
233
|
return this.json(value);
|
|
253
234
|
}
|
package/dist/src/cookie.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Context } from "./context.ts";
|
|
1
2
|
export type CookieOptions = {
|
|
2
3
|
maxAge?: number;
|
|
3
4
|
expires?: Date;
|
|
@@ -11,3 +12,7 @@ export type CookieOptions = {
|
|
|
11
12
|
export declare function parseCookies(header: string | null): Record<string, string>;
|
|
12
13
|
export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
|
|
13
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;
|
package/dist/src/cookie.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { senriError } from "./errors.js";
|
|
2
2
|
// Cookie の名前に使える文字(RFC 6265 の token)
|
|
3
3
|
const NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
4
4
|
// "Cookie: a=1; b=2" を { a: "1", b: "2" } に。値はデコードする(できなければそのまま)
|
|
@@ -28,7 +28,7 @@ export function parseCookies(header) {
|
|
|
28
28
|
}
|
|
29
29
|
export function serializeCookie(name, value, options = {}) {
|
|
30
30
|
if (!NAME.test(name)) {
|
|
31
|
-
throw new TypeError(`
|
|
31
|
+
throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
|
|
32
32
|
}
|
|
33
33
|
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
34
34
|
if (options.maxAge !== undefined)
|
|
@@ -51,7 +51,7 @@ export function serializeCookie(name, value, options = {}) {
|
|
|
51
51
|
// 消すための Set-Cookie。Path と Domain は、付けたときと同じものを指定する
|
|
52
52
|
export function deletionCookie(name, options = {}) {
|
|
53
53
|
if (!NAME.test(name)) {
|
|
54
|
-
throw new TypeError(`
|
|
54
|
+
throw senriError("cookie_name", new TypeError(`Invalid cookie name: ${JSON.stringify(name)}`));
|
|
55
55
|
}
|
|
56
56
|
const parts = [`${name}=`, "Max-Age=0", "Expires=Thu, 01 Jan 1970 00:00:00 GMT"];
|
|
57
57
|
if (options.domain)
|
|
@@ -59,3 +59,15 @@ export function deletionCookie(name, options = {}) {
|
|
|
59
59
|
parts.push(`Path=${options.path ?? "/"}`);
|
|
60
60
|
return parts.join("; ");
|
|
61
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,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
|
+
}
|
package/dist/src/dev/hints.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// よくあるエラーに、直し方のヒントを付ける(ルールベース。当てはまらなければ何も出さない)
|
|
2
|
+
// エラーメッセージの書き方はランタイムで違う(Bun と、V8 の Node / Deno)ので、両方を見る
|
|
3
|
+
import { errorCode } from "../errors.js";
|
|
1
4
|
// 利用者のコードが投げたエラーか(ランタイム内部のフレームは飛ばして、いちばん上を見る)
|
|
2
5
|
// スタックが分からなければ、利用者のものとして扱う
|
|
3
6
|
function thrownByUser(frames) {
|
|
@@ -16,16 +19,16 @@ function propertyOfNothing(message) {
|
|
|
16
19
|
}
|
|
17
20
|
const rules = [
|
|
18
21
|
// Senri 自身が投げるエラー
|
|
19
|
-
(err) => err
|
|
22
|
+
(err) => errorCode(err) === "next_twice"
|
|
20
23
|
? {
|
|
21
24
|
text: "1つのミドルウェアで `next()` を2回呼んでいます。`await next()` は1回だけにして、結果を変数に入れて使ってください。",
|
|
22
25
|
code: "const res = await next();",
|
|
23
26
|
}
|
|
24
27
|
: null,
|
|
25
|
-
(err) => err
|
|
28
|
+
(err) => errorCode(err) === "returned_function"
|
|
26
29
|
? { text: "関数を呼び出さずに返しています。`()` を付けて呼び出した結果を返してください。", code: "return getUser();" }
|
|
27
30
|
: null,
|
|
28
|
-
(err) => err
|
|
31
|
+
(err) => errorCode(err) === "returned_nothing"
|
|
29
32
|
? { text: "ハンドラーが値を返していません。`return` を忘れていないか確かめてください。", code: 'return "ok";' }
|
|
30
33
|
: null,
|
|
31
34
|
// undefined / null のプロパティを読んだ
|
package/dist/src/dev/index.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export { serverTimingHeader, withServerTiming } from "./server-timing.ts";
|
|
5
|
-
export declare function registerDashboard(app: object, dashboard: Dashboard): void;
|
|
6
|
-
export declare function dashboardOf(app: object): Promise<Dashboard | undefined>;
|
|
1
|
+
import type { DevHooks } from "../app.ts";
|
|
2
|
+
export declare function devtools(): DevHooks | undefined;
|
|
3
|
+
export declare function isLocalUrl(url: string): boolean;
|
package/dist/src/dev/index.js
CHANGED
|
@@ -1,11 +1,185 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { Context } from "../context.js";
|
|
2
|
+
import { compose } from "../compose.js";
|
|
3
|
+
import { defaultNotFound, defaultOnError, errorCode, HttpError } from "../errors.js";
|
|
4
|
+
import { compareRoutes } from "../router.js";
|
|
5
|
+
import * as runtime from "../runtime.js";
|
|
6
|
+
import { ValidationError } from "../validate.js";
|
|
7
|
+
import { createDashboard } from "./dashboard/index.js";
|
|
8
|
+
import { registerDashboard } from "./registry.js";
|
|
9
|
+
import { explain } from "./explain.js";
|
|
10
|
+
import { devNotFound } from "./not-found.js";
|
|
11
|
+
import { finishErrorPage, withErrorPage } from "./pages.js";
|
|
12
|
+
import { serverTimingHeader, withServerTiming } from "./server-timing.js";
|
|
13
|
+
import { checkSchemas, describeValidation } from "./validation.js";
|
|
14
|
+
// 計測で、いちばん内側の層に付ける名前(ダッシュボードの玉ねぎタイムラインに出る)
|
|
15
|
+
const INNER_NAME = {
|
|
16
|
+
route: "handler",
|
|
17
|
+
notFound: "notFound",
|
|
18
|
+
methodNotAllowed: "405",
|
|
19
|
+
options: "options",
|
|
20
|
+
badRequest: "400",
|
|
21
|
+
notImplemented: "501",
|
|
22
|
+
};
|
|
23
|
+
// 開発モードのフックを作る。本番(NODE_ENV=production)では undefined を返すので、開発モードにならない
|
|
24
|
+
// Deno で NODE_ENV を読む許可がないときは、本番かどうか分からないので、開発モードにしない(案内を1行出す)
|
|
25
|
+
export function devtools() {
|
|
26
|
+
if (!runtime.canReadEnv("NODE_ENV")) {
|
|
27
|
+
console.warn("[senri] devtools() is off: NODE_ENV is not readable. Run with --allow-env=NODE_ENV to enable it. / NODE_ENV を読めないので devtools() は無効です。--allow-env=NODE_ENV を付けると使えます");
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (runtime.env("NODE_ENV") === "production")
|
|
31
|
+
return undefined;
|
|
32
|
+
// Workers(nodejs_compat で process があっても、NODE_ENV はふつう入っていない)とブラウザは、本番かどうか分からない
|
|
33
|
+
const localOnly = runtime.isWorkers() || typeof globalThis.process === "undefined";
|
|
34
|
+
return { install: (core) => install(core, localOnly) };
|
|
35
|
+
}
|
|
36
|
+
// request.url のホスト名が localhost か(Host や X-Forwarded-Host などのヘッダーは見ない。だれでも付けられるので)
|
|
37
|
+
// - Workers: Cloudflare は登録されたドメインのリクエストしか渡さないので、本番に localhost は来ない。wrangler dev は localhost
|
|
38
|
+
// - ブラウザ: Service Worker が受けるリクエストの URL は、そのサイトのオリジン。開発中だけ localhost になる
|
|
39
|
+
export function isLocalUrl(url) {
|
|
40
|
+
let host;
|
|
41
|
+
try {
|
|
42
|
+
host = new URL(url).hostname.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return (host === "localhost" ||
|
|
48
|
+
host.endsWith(".localhost") ||
|
|
49
|
+
host === "[::1]" ||
|
|
50
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
|
|
51
|
+
}
|
|
52
|
+
// パスが /_senri か /_senri/... なら、"_senri" より後ろのセグメント。そうでなければ null
|
|
53
|
+
function underDashboard(c) {
|
|
54
|
+
try {
|
|
55
|
+
return c.segments[0] === "_senri" ? c.segments.slice(1) : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 層ごとの時間を c.timings に記録する(src/compose.ts の Trace)
|
|
62
|
+
function measure(c, origin, layers, innerName) {
|
|
63
|
+
const starts = [];
|
|
64
|
+
const inner = [];
|
|
65
|
+
return {
|
|
66
|
+
enter(i) {
|
|
67
|
+
starts[i] = performance.now();
|
|
68
|
+
inner[i] = 0;
|
|
69
|
+
},
|
|
70
|
+
down(i) {
|
|
71
|
+
const start = performance.now();
|
|
72
|
+
return () => (inner[i] += performance.now() - start);
|
|
73
|
+
},
|
|
74
|
+
leave(i, failed, stopped) {
|
|
75
|
+
const total = performance.now() - starts[i];
|
|
76
|
+
c.timings[i] = {
|
|
77
|
+
name: layers[i]?.name ?? innerName,
|
|
78
|
+
total,
|
|
79
|
+
self: total - inner[i],
|
|
80
|
+
failed,
|
|
81
|
+
start: starts[i] - origin,
|
|
82
|
+
stopped,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// 開発モードの既定の onError。本番の固定の文言の代わりに、詳しい文言を返す
|
|
88
|
+
// (ブラウザから開いた 5xx は、withErrorPage がエラーページにする)
|
|
89
|
+
const devOnError = (err, c) => {
|
|
90
|
+
if (err instanceof ValidationError)
|
|
91
|
+
return c.text(describeValidation(err));
|
|
92
|
+
if (err instanceof HttpError) {
|
|
93
|
+
switch (errorCode(err)) {
|
|
94
|
+
case "method_not_allowed":
|
|
95
|
+
return c.text(`405 Method Not Allowed: ${c.req.method} ${c.path}\n${err.headers.get("allow")} ならあります`);
|
|
96
|
+
case "query_no_body":
|
|
97
|
+
return c.text("400 Bad Request: QUERY リクエストにボディがありません\nQUERY は、検索の条件をボディに入れて送るメソッドです");
|
|
98
|
+
case "query_no_type":
|
|
99
|
+
return c.text("415 Unsupported Media Type: QUERY リクエストに Content-Type がありません\nボディの形式を指定してください(例: Content-Type: application/json)");
|
|
100
|
+
}
|
|
101
|
+
return c.text(err.message);
|
|
102
|
+
}
|
|
103
|
+
explain(err);
|
|
104
|
+
return c.text(`500 Internal Server Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
+
};
|
|
106
|
+
// localOnly: localhost へのリクエストのときだけ開発用の出力をする(Workers とブラウザ)
|
|
107
|
+
function install(core, localOnly) {
|
|
108
|
+
const { app } = core;
|
|
109
|
+
// 本番と同じに振る舞うリクエスト(localOnly で、localhost でないもの)
|
|
110
|
+
const quiet = new WeakSet();
|
|
111
|
+
// 一覧は優先順位の順(静的なセグメントが先)。本体は登録順に持っている
|
|
112
|
+
const sortedRoutes = () => [...core.routes()].sort(compareRoutes);
|
|
113
|
+
// ダッシュボード。Cloudflare Workers では作らない(接続元が分からず localhost 制限で開けないうえ、
|
|
114
|
+
// リクエストごとに別の isolate で動くことがあり、記録が残らない)。/_senri はふつうの 404 になる
|
|
115
|
+
const dashboard = runtime.isWorkers()
|
|
116
|
+
? null
|
|
117
|
+
: createDashboard({
|
|
118
|
+
routes: () => sortedRoutes().map((r) => ({ method: r.method, path: r.path })),
|
|
119
|
+
middlewares: () => app.middlewareList(),
|
|
120
|
+
});
|
|
121
|
+
if (dashboard)
|
|
122
|
+
registerDashboard(app, dashboard);
|
|
123
|
+
async function fetch(req, info, ctx) {
|
|
124
|
+
// 計測の基準になるリクエストの開始時刻
|
|
125
|
+
const origin = performance.now();
|
|
126
|
+
const time = Date.now();
|
|
127
|
+
const c = new Context(req, info, ctx);
|
|
128
|
+
if (localOnly && !isLocalUrl(req.url))
|
|
129
|
+
return quietFetch(c);
|
|
130
|
+
// /_senri 以下はダッシュボード。localhost から開いたときだけ、ミドルウェアを通さずに処理する
|
|
131
|
+
// (開けないときは普通の処理に回すので、/_senri はないものとして 404 になる)。どちらも記録はしない
|
|
132
|
+
const dashboardPath = underDashboard(c);
|
|
133
|
+
if (dashboard && dashboardPath && dashboard.canOpen(req, info)) {
|
|
134
|
+
return dashboard.handle(req, dashboardPath);
|
|
135
|
+
}
|
|
136
|
+
if (!dashboardPath)
|
|
137
|
+
dashboard?.begin(c, info);
|
|
138
|
+
// ミドルウェアより先にマッチして、c.route / c.params を見られるようにしておく
|
|
139
|
+
const resolved = core.resolve(c);
|
|
140
|
+
if (resolved.kind === "route") {
|
|
141
|
+
c.route = resolved.route.path;
|
|
142
|
+
c.params = resolved.params;
|
|
143
|
+
}
|
|
144
|
+
// ハンドラー・notFound・toResponse のどこで投げても、compose が onError に集める
|
|
145
|
+
const layers = core.layers(c);
|
|
146
|
+
const innerName = INNER_NAME[resolved.kind];
|
|
147
|
+
const res = await compose(layers, c, () => core.inner(c, resolved), (err) => core.fail(err, c), measure(c, origin, layers, innerName));
|
|
148
|
+
// ミドルウェアを全部通ったあとなので、ここなら「通った順番」が揃っている
|
|
149
|
+
const page = await finishErrorPage(c, res);
|
|
150
|
+
// 層ごとの時間を Server-Timing に載せる(ブラウザの開発者ツールで見られる)
|
|
151
|
+
const desc = resolved.kind === "route" ? `${resolved.route.method} ${resolved.route.path}` : innerName;
|
|
152
|
+
const final = withServerTiming(c.finalize(page), serverTimingHeader(c.timings, { name: innerName, desc }, performance.now() - origin));
|
|
153
|
+
// ダッシュボードに記録する(失敗してもレスポンスには影響しない)
|
|
154
|
+
await dashboard?.record(c, final, time, [
|
|
155
|
+
...layers.map((l) => ({ name: l.name, order: l.order ?? null })),
|
|
156
|
+
{ name: innerName, order: null },
|
|
157
|
+
]);
|
|
158
|
+
return final;
|
|
159
|
+
}
|
|
160
|
+
// 本番の fetch と同じ流れ(計測・記録・エラーページ・Server-Timing なし。onError / notFound も本番の既定になる)
|
|
161
|
+
function quietFetch(c) {
|
|
162
|
+
quiet.add(c);
|
|
163
|
+
const resolved = core.resolve(c);
|
|
164
|
+
if (resolved.kind === "route") {
|
|
165
|
+
c.route = resolved.route.path;
|
|
166
|
+
c.params = resolved.params;
|
|
167
|
+
}
|
|
168
|
+
return compose(core.layers(c), c, () => core.inner(c, resolved), (err) => core.fail(err, c)).then((res) => c.finalize(res));
|
|
169
|
+
}
|
|
170
|
+
const pageOnError = withErrorPage(devOnError, true);
|
|
171
|
+
return {
|
|
172
|
+
fetch,
|
|
173
|
+
onError: (err, c) => (quiet.has(c) ? defaultOnError(err, c) : pageOnError(err, c)),
|
|
174
|
+
notFound: (c) => (quiet.has(c) ? defaultNotFound(c) : devNotFound(c, sortedRoutes())),
|
|
175
|
+
checkRoute(method, path, schemas) {
|
|
176
|
+
if (schemas)
|
|
177
|
+
checkSchemas(path, schemas);
|
|
178
|
+
// /_senri 以下はダッシュボードが使うので、登録しても呼ばれない。気づけるようにエラーにする
|
|
179
|
+
if (dashboard && /^\/*_senri(\/|$)/.test(path)) {
|
|
180
|
+
throw new Error(`${method} ${path}: /_senri 以下は、開発モードではダッシュボード用に予約されています`);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
explain,
|
|
184
|
+
};
|
|
11
185
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { notFoundPage } from "./not-found-page.js";
|
|
2
|
+
import { wantsHtml } from "./render.js";
|
|
3
|
+
import { suggest } from "./suggest.js";
|
|
4
|
+
export function devNotFound(c, routes) {
|
|
5
|
+
if (wantsHtml(c.req))
|
|
6
|
+
return notFoundPage(c, routes);
|
|
7
|
+
// ルートと同じく、デコード・正規化したパスで比べる(notFound に来るのはデコードできたリクエストだけ)
|
|
8
|
+
const path = c.path;
|
|
9
|
+
let message = `404 Not Found: ${c.req.method} ${path}`;
|
|
10
|
+
const closest = suggest(routes, path);
|
|
11
|
+
if (closest) {
|
|
12
|
+
message += `\nもしかして: ${closest.method} ${closest.path} ?`;
|
|
13
|
+
}
|
|
14
|
+
return c.text(message);
|
|
15
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// バリデーションの、開発モードだけの部分(本体を小さくするため、本体の src/validate.ts から分けている)
|
|
2
|
+
// - 登録した時点で、スキーマの書き間違いに気づけるようにする
|
|
3
|
+
// - 通らなかったとき、どこが違うかを一覧にする
|
|
4
|
+
import { senriError } from "../errors.js";
|
|
5
|
+
const LABEL = {
|
|
6
|
+
params: "パスパラメーター",
|
|
7
|
+
query: "クエリ",
|
|
8
|
+
body: "ボディ",
|
|
9
|
+
};
|
|
10
|
+
// ["user", 0, "name"] → "user[0].name"
|
|
11
|
+
function issuePath(issue) {
|
|
12
|
+
let out = "";
|
|
13
|
+
for (const part of issue.path ?? []) {
|
|
14
|
+
const key = typeof part === "object" ? part.key : part;
|
|
15
|
+
if (typeof key === "number")
|
|
16
|
+
out += `[${key}]`;
|
|
17
|
+
else
|
|
18
|
+
out += out ? `.${String(key)}` : String(key);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
// 開発モードで返す、どこが違うかの一覧
|
|
23
|
+
export function describeValidation(err) {
|
|
24
|
+
const lines = err.issues.map((issue) => {
|
|
25
|
+
const path = issuePath(issue);
|
|
26
|
+
return `- ${path ? `${path}: ` : ""}${issue.message}`;
|
|
27
|
+
});
|
|
28
|
+
return [`400 Bad Request: ${LABEL[err.target]}が、スキーマに合いません`, ...lines].join("\n");
|
|
29
|
+
}
|
|
30
|
+
// ArkType のスキーマは関数なので、オブジェクトでも関数でもよい
|
|
31
|
+
function isSchema(value) {
|
|
32
|
+
return (typeof value === "object" || typeof value === "function") && value !== null && "~standard" in value;
|
|
33
|
+
}
|
|
34
|
+
// スキーマを確かめておく(書き間違いに、登録した時点で気づけるように)
|
|
35
|
+
export function checkSchemas(path, schemas) {
|
|
36
|
+
if (typeof schemas !== "object" || schemas === null) {
|
|
37
|
+
throw senriError("schema_shape", new TypeError(`${path}: 2つ目の引数には { params, query, body } のスキーマかハンドラーを渡してください`));
|
|
38
|
+
}
|
|
39
|
+
for (const [key, schema] of Object.entries(schemas)) {
|
|
40
|
+
if (key !== "params" && key !== "query" && key !== "body") {
|
|
41
|
+
throw senriError("schema_key", new TypeError(`${path}: スキーマに渡せるのは params / query / body だけです("${key}" は使えません)`));
|
|
42
|
+
}
|
|
43
|
+
if (schema !== undefined && !isSchema(schema)) {
|
|
44
|
+
throw senriError("schema_type", new TypeError(`${path}: ${key} が Standard Schema のスキーマではありません(Zod・Valibot・ArkType などのスキーマを渡してください)`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Context, HandlerResult } from "./context.ts";
|
|
2
|
-
import type { Route } from "./router.ts";
|
|
3
2
|
export declare class HttpError extends Error {
|
|
4
3
|
status: number;
|
|
5
4
|
headers: Headers;
|
|
@@ -17,7 +16,10 @@ export declare function conversionOriginOf(err: unknown): ConversionOrigin | nul
|
|
|
17
16
|
export type ErrorHandler<V extends object = any> = (err: unknown, c: Context<V>) => HandlerResult | Promise<HandlerResult>;
|
|
18
17
|
export type NotFoundHandler<V extends object = any> = (c: Context<V>) => HandlerResult | Promise<HandlerResult>;
|
|
19
18
|
export declare function handleError(err: unknown, c: Context, onError: ErrorHandler): Promise<Response>;
|
|
20
|
-
export declare function
|
|
21
|
-
export declare function
|
|
22
|
-
export declare function
|
|
23
|
-
export declare
|
|
19
|
+
export declare function senriError<E extends Error>(code: string, error: E): E;
|
|
20
|
+
export declare function senriError(code: string, message: string): Error;
|
|
21
|
+
export declare function errorCode(err: unknown): string | undefined;
|
|
22
|
+
export declare const defaultOnError: ErrorHandler;
|
|
23
|
+
export declare const defaultNotFound: NotFoundHandler;
|
|
24
|
+
export declare function methodNotAllowed(methods: string): HttpError;
|
|
25
|
+
export declare function checkQueryRequest(c: Context): Promise<HttpError | null>;
|