@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
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { Context } from "./context.js";
|
|
2
|
+
import { compareRoutes, allowedMethods, checkParamNames, normalizeMethod, routeShape, splitPattern, startsWithSegments, } from "./router.js";
|
|
3
|
+
import { compose } from "./compose.js";
|
|
4
|
+
import { createMatcher } from "./matcher.js";
|
|
5
|
+
import { isWorkers } from "./runtime.js";
|
|
6
|
+
import { groupEntries, joinPath, routeHandler } from "./group.js";
|
|
7
|
+
import { defaultNotFound, defaultOnError, checkQueryRequest, handleError, HttpError, markConversionError, methodNotAllowed, } from "./errors.js";
|
|
8
|
+
// パスが /_senri か /_senri/... なら、"_senri" より後ろのセグメント。そうでなければ null
|
|
9
|
+
function underDashboard(c) {
|
|
10
|
+
try {
|
|
11
|
+
return c.segments[0] === "_senri" ? c.segments.slice(1) : null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// 計測で、いちばん内側の層に付ける名前(ダッシュボードの玉ねぎタイムラインに出る)
|
|
18
|
+
const INNER_NAME = {
|
|
19
|
+
route: "handler",
|
|
20
|
+
notFound: "notFound",
|
|
21
|
+
methodNotAllowed: "405",
|
|
22
|
+
options: "options",
|
|
23
|
+
badRequest: "400",
|
|
24
|
+
notImplemented: "501",
|
|
25
|
+
};
|
|
26
|
+
// 扱わないメソッド。ルートがあってもなくても 501 にする(app.all() でも受けない)
|
|
27
|
+
const NOT_IMPLEMENTED = new Set(["CONNECT", "TRACE", "TRACK"]);
|
|
28
|
+
// app.route(prefix, app) でつなげるように、アプリに登録されたものを横に置いておく
|
|
29
|
+
const appEntries = new WeakMap();
|
|
30
|
+
// V はアプリの型。c.set / c.get で使う変数の型(createApp<{ user: User }>())か、
|
|
31
|
+
// Workers の env も含めた形(createApp<{ Bindings: Env; Variables: { user: User } }>())で指定する(src/context.ts)
|
|
32
|
+
export function createApp(options = {}) {
|
|
33
|
+
const dev = options.dev ?? false;
|
|
34
|
+
const routes = [];
|
|
35
|
+
const matcher = createMatcher();
|
|
36
|
+
// prefix が null なら全部のリクエストで動く。["admin"] なら /admin とその下だけ
|
|
37
|
+
// anonymous: 無名の関数だった(名前を anonymous#番号 にした)。ダッシュボードで「名前を付けると出る」と案内する
|
|
38
|
+
const middlewares = [];
|
|
39
|
+
// 開発モードだけで使うもの(エラーページ、ダッシュボード)。ビルド済みの HTML など大きいので、
|
|
40
|
+
// 本番では読み込まないよう dev のときだけ import() する(src/dev/index.ts)
|
|
41
|
+
const devTools = dev ? import("./dev/index.js") : null;
|
|
42
|
+
// 開発モードでは、ブラウザから開いた 5xx を HTML のエラーページにする(src/dev/pages.ts)
|
|
43
|
+
const baseOnError = defaultOnError(dev);
|
|
44
|
+
let onError = devTools
|
|
45
|
+
? async (err, c) => (await devTools).withErrorPage(baseOnError, true)(err, c)
|
|
46
|
+
: baseOnError;
|
|
47
|
+
let notFound = defaultNotFound(routes, dev);
|
|
48
|
+
function method(name) {
|
|
49
|
+
return ((path, a, b) => add(name, path, routeHandler(path, a, b)));
|
|
50
|
+
}
|
|
51
|
+
function add(rawMethod, path, handler) {
|
|
52
|
+
// app.all() の印の "ALL" だけはそのまま。ほかは名前を確かめて大文字にする
|
|
53
|
+
const method = rawMethod === "ALL" ? rawMethod : normalizeMethod(rawMethod);
|
|
54
|
+
const segments = splitPattern(path);
|
|
55
|
+
checkParamNames(path, segments);
|
|
56
|
+
// 開発モードでは /_senri 以下はダッシュボードが使うので、登録しても呼ばれない。気づけるようにエラーにする
|
|
57
|
+
if (dev && segments[0] === "_senri") {
|
|
58
|
+
throw new Error(`${method} ${path}: /_senri 以下は、開発モードではダッシュボード用に予約されています`);
|
|
59
|
+
}
|
|
60
|
+
// 同じメソッド + 同じ形のパスがあったら、あとから登録したほうは絶対に呼ばれないのでエラーにする
|
|
61
|
+
const shape = routeShape(segments);
|
|
62
|
+
const dup = routes.find((r) => r.method === method && routeShape(r.segments) === shape);
|
|
63
|
+
if (dup) {
|
|
64
|
+
const samePath = dup.segments.join("/") === segments.join("/");
|
|
65
|
+
throw new Error(samePath
|
|
66
|
+
? `${method} ${path} はすでに登録されています`
|
|
67
|
+
: `${method} ${path} は、登録済みの ${dup.method} ${dup.path} と同じパスです(パラメーター名が違うだけです)`);
|
|
68
|
+
}
|
|
69
|
+
const paramNames = segments.filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
70
|
+
const route = { path, segments, paramNames, method, handler };
|
|
71
|
+
// routes は一覧(routeList や 404 の「もしかして」)用。探すのは matcher(Trie)で行う
|
|
72
|
+
routes.push(route);
|
|
73
|
+
routes.sort(compareRoutes);
|
|
74
|
+
matcher.add(route);
|
|
75
|
+
}
|
|
76
|
+
// マッチしたルートのパラメーターを、ルートごとの名前で組み立てる
|
|
77
|
+
function paramsOf(route, values) {
|
|
78
|
+
const params = {};
|
|
79
|
+
const names = route.paramNames;
|
|
80
|
+
for (let i = 0; i < names.length; i++)
|
|
81
|
+
params[names[i]] = values[i];
|
|
82
|
+
return params;
|
|
83
|
+
}
|
|
84
|
+
function resolve(c) {
|
|
85
|
+
let path;
|
|
86
|
+
try {
|
|
87
|
+
path = c.path;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// エンコードが不正(HttpError 400)。ミドルウェアは動かしてから返す
|
|
91
|
+
return { kind: "badRequest", error };
|
|
92
|
+
}
|
|
93
|
+
const method = c.req.method;
|
|
94
|
+
if (NOT_IMPLEMENTED.has(method))
|
|
95
|
+
return { kind: "notImplemented" };
|
|
96
|
+
// 近道: パラメーターのないルートにぴったり合い、メソッドも合えば、それがいちばん優先される
|
|
97
|
+
// (この場合はパスを分割しなくて済む)
|
|
98
|
+
const exact = matcher.staticLeaf(path);
|
|
99
|
+
if (exact) {
|
|
100
|
+
for (const route of exact) {
|
|
101
|
+
if (route.method === method)
|
|
102
|
+
return { kind: "route", route, params: {} };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// c.path が読めたので、ここで分割に失敗することはない
|
|
106
|
+
const segments = c.segments;
|
|
107
|
+
const find = (m) => {
|
|
108
|
+
const hit = matcher.first(segments, m);
|
|
109
|
+
return hit ? { kind: "route", route: hit.route, params: paramsOf(hit.route, hit.values) } : undefined;
|
|
110
|
+
};
|
|
111
|
+
// 1. メソッドが合うもの → 2. HEAD なら GET → 3. all の順に、優先順位の高いルートから探す
|
|
112
|
+
const hit = find(method) ?? (method === "HEAD" ? find("GET") : undefined) ?? find("ALL");
|
|
113
|
+
if (hit)
|
|
114
|
+
return hit;
|
|
115
|
+
// どれもなければ、パスが合うルートを全部集めて 404 か 405 かを決める
|
|
116
|
+
const matched = matcher.match(segments);
|
|
117
|
+
if (matched.length === 0)
|
|
118
|
+
return { kind: "notFound" };
|
|
119
|
+
const allow = allowedMethods(matched.flatMap((x) => x.leaf));
|
|
120
|
+
return method === "OPTIONS" ? { kind: "options", allow } : { kind: "methodNotAllowed", allow };
|
|
121
|
+
}
|
|
122
|
+
// ミドルウェアがないときに毎回配列を作らないよう、共有の空配列を返す(中身は変えない)
|
|
123
|
+
const NO_LAYERS = [];
|
|
124
|
+
function applicableMiddlewares(c) {
|
|
125
|
+
if (middlewares.length === 0)
|
|
126
|
+
return NO_LAYERS;
|
|
127
|
+
let segments;
|
|
128
|
+
try {
|
|
129
|
+
segments = c.segments;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// パスが不正なら、パス指定のミドルウェアは動かさない(どのみちルーターが 400 にする)
|
|
133
|
+
segments = null;
|
|
134
|
+
}
|
|
135
|
+
const layers = [];
|
|
136
|
+
for (let i = 0; i < middlewares.length; i++) {
|
|
137
|
+
const { name, mw, prefix } = middlewares[i];
|
|
138
|
+
if (prefix === null || (segments !== null && startsWithSegments(segments, prefix))) {
|
|
139
|
+
// order は登録順(1 から)。同じ関数を2回登録しても区別できるよう、配列の位置から決める
|
|
140
|
+
// (ダッシュボードで、名前ではなく登録順で集計するのに使う)
|
|
141
|
+
layers.push({ name, mw, order: i + 1 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return layers;
|
|
145
|
+
}
|
|
146
|
+
const app = {
|
|
147
|
+
use(pathOrMw, mw) {
|
|
148
|
+
const path = typeof pathOrMw === "string" ? pathOrMw : null;
|
|
149
|
+
const fn = typeof pathOrMw === "string" ? mw : pathOrMw;
|
|
150
|
+
if (!fn)
|
|
151
|
+
throw new Error(`app.use("${path}", ...) にミドルウェアがありません`);
|
|
152
|
+
middlewares.push({
|
|
153
|
+
// 関数の名前を使う。無名の関数(その場で書いたアロー関数など)は登録順の番号で呼ぶ
|
|
154
|
+
name: fn.name || `anonymous#${middlewares.length + 1}`,
|
|
155
|
+
anonymous: !fn.name,
|
|
156
|
+
path,
|
|
157
|
+
prefix: path === null ? null : splitPattern(path),
|
|
158
|
+
mw: fn,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
onError(handler) {
|
|
162
|
+
onError = handler;
|
|
163
|
+
},
|
|
164
|
+
notFound(handler) {
|
|
165
|
+
notFound = handler;
|
|
166
|
+
},
|
|
167
|
+
// どれも app.get(path, handler) か、スキーマを付けた app.get(path, { params, query, body }, handler)(src/validate.ts)
|
|
168
|
+
get: method("GET"),
|
|
169
|
+
post: method("POST"),
|
|
170
|
+
put: method("PUT"),
|
|
171
|
+
patch: method("PATCH"),
|
|
172
|
+
delete: method("DELETE"),
|
|
173
|
+
// 登録しなければ GET を使う(ボディは送らない)。登録すれば GET より優先される
|
|
174
|
+
head: method("HEAD"),
|
|
175
|
+
// 登録しなければ 204 と Allow を自動で返す。登録すればそちらが優先される
|
|
176
|
+
options: method("OPTIONS"),
|
|
177
|
+
// どのメソッドでも動く。ただし、メソッドが合うルートがあればそちらが優先
|
|
178
|
+
all: method("ALL"),
|
|
179
|
+
// 任意のメソッド(QUERY や、PROPFIND などの WebDAV)を登録する。配列でまとめて登録もできる
|
|
180
|
+
// 小文字で書いても大文字にする。CONNECT / TRACE / TRACK は扱わない
|
|
181
|
+
on: ((methods, path, a, b) => {
|
|
182
|
+
const list = typeof methods === "string" ? [methods] : methods;
|
|
183
|
+
if (list.length === 0)
|
|
184
|
+
throw new Error(`app.on(..., "${path}") にメソッドがありません`);
|
|
185
|
+
const handler = routeHandler(path, a, b);
|
|
186
|
+
for (const m of list) {
|
|
187
|
+
if (m.toUpperCase() === "ALL")
|
|
188
|
+
throw new Error("すべてのメソッドで動かすには app.all() を使ってください");
|
|
189
|
+
add(m, path, handler);
|
|
190
|
+
}
|
|
191
|
+
}),
|
|
192
|
+
// K はテーブルのキーから推論される。各キーのパスからパラメーターの型が決まる
|
|
193
|
+
routes(table) {
|
|
194
|
+
for (const [key, handler] of Object.entries(table)) {
|
|
195
|
+
const [method, path] = key.split(" ");
|
|
196
|
+
if (!method || !path) {
|
|
197
|
+
throw new Error(`ルートの書き方が違います: "${key}"\n例: "GET /hello"`);
|
|
198
|
+
}
|
|
199
|
+
add(method.toUpperCase(), path, handler);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
// createRouter() で作ったルーターをつなげる。ルーターに登録済みのものが、この時点でアプリに入る
|
|
203
|
+
// app.route(prefix, app) なら、別のアプリをプレフィックス付きでつなげる
|
|
204
|
+
// (つなぐ側のアプリでは、プレフィックスのパラメーターに型は付かない。型が必要なら createRouter を使う)
|
|
205
|
+
// つないだアプリの onError / notFound は使わず、こちらのものを使う
|
|
206
|
+
route(routerOrPrefix, child) {
|
|
207
|
+
// 先に全部読んでおく(ルーターの作りがおかしいときに、途中まで登録された状態にしない)
|
|
208
|
+
let list;
|
|
209
|
+
if (typeof routerOrPrefix === "string") {
|
|
210
|
+
const read = child ? appEntries.get(child) : undefined;
|
|
211
|
+
if (!read)
|
|
212
|
+
throw new TypeError(`app.route("${routerOrPrefix}", app) には createApp() で作ったアプリを渡してください`);
|
|
213
|
+
list = read().map((entry) => ({ ...entry, path: joinPath(routerOrPrefix, entry.path) }));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
list = groupEntries(routerOrPrefix);
|
|
217
|
+
}
|
|
218
|
+
for (const entry of list) {
|
|
219
|
+
if (entry.kind === "use")
|
|
220
|
+
app.use(entry.path, entry.mw);
|
|
221
|
+
else
|
|
222
|
+
add(entry.method, entry.path, entry.handler);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
middlewareList() {
|
|
226
|
+
return middlewares.map((m) => ({ name: m.name, path: m.path, anonymous: m.anonymous }));
|
|
227
|
+
},
|
|
228
|
+
routeList() {
|
|
229
|
+
return routes.map((r) => ({ method: r.method, path: r.path }));
|
|
230
|
+
},
|
|
231
|
+
// info: ランタイムが渡す2つ目の引数(Bun の server、Deno の info など)。接続元のアドレスを取るのに使う
|
|
232
|
+
// (形で見分けるので、Cloudflare Workers の env のような別のものが来ても壊れない。src/dev/dashboard/access.ts)
|
|
233
|
+
// ctx: Cloudflare Workers の3つ目の引数(c.executionCtx)。export default app で、Workers がそのまま呼ぶ
|
|
234
|
+
fetch(req, info, ctx) {
|
|
235
|
+
// 開発モードは計測・記録をするので別の道を通る。本番では、その分のコードを一切通らない
|
|
236
|
+
if (devTools)
|
|
237
|
+
return devFetch(req, info, ctx);
|
|
238
|
+
try {
|
|
239
|
+
const c = new Context(req, info, ctx);
|
|
240
|
+
const resolved = resolve(c);
|
|
241
|
+
if (resolved.kind === "route") {
|
|
242
|
+
c.route = resolved.route.path;
|
|
243
|
+
c.params = resolved.params;
|
|
244
|
+
}
|
|
245
|
+
const fail = (err) => handleError(err, c, onError);
|
|
246
|
+
const layers = applicableMiddlewares(c);
|
|
247
|
+
if (layers.length > 0) {
|
|
248
|
+
return compose(layers, c, () => inner(c, resolved), fail).then((res) => c.finalize(res));
|
|
249
|
+
}
|
|
250
|
+
// 近道: ミドルウェアがなければ compose を通さない。同期のハンドラーなら、待たずにそのまま返す
|
|
251
|
+
let res;
|
|
252
|
+
try {
|
|
253
|
+
res = inner(c, resolved);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
res = fail(err);
|
|
257
|
+
}
|
|
258
|
+
if (res instanceof Promise)
|
|
259
|
+
return res.catch(fail).then((r) => c.finalize(r));
|
|
260
|
+
return Promise.resolve(c.finalize(res));
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
return Promise.reject(err);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
// ミドルウェアを全部通ったあとに動く、いちばん内側の処理
|
|
268
|
+
// 同期で返せるときは Response をそのまま返す(Promise を作らない)
|
|
269
|
+
function inner(c, resolved) {
|
|
270
|
+
switch (resolved.kind) {
|
|
271
|
+
case "badRequest":
|
|
272
|
+
throw resolved.error;
|
|
273
|
+
case "route": {
|
|
274
|
+
const route = resolved.route;
|
|
275
|
+
// QUERY として登録したルートだけ、ボディと Content-Type を確かめる(app.all はハンドラーに任せる)
|
|
276
|
+
if (route.method === "QUERY") {
|
|
277
|
+
return checkQueryRequest(c, dev).then((invalid) => {
|
|
278
|
+
if (invalid)
|
|
279
|
+
throw invalid;
|
|
280
|
+
return callHandler(c, route);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return callHandler(c, route);
|
|
284
|
+
}
|
|
285
|
+
case "options":
|
|
286
|
+
return new Response(null, { status: 204, headers: { allow: resolved.allow } });
|
|
287
|
+
case "methodNotAllowed":
|
|
288
|
+
throw methodNotAllowed(c, c.path, resolved.allow, dev);
|
|
289
|
+
case "notImplemented":
|
|
290
|
+
throw new HttpError(501, "501 Not Implemented");
|
|
291
|
+
case "notFound":
|
|
292
|
+
c.status(404);
|
|
293
|
+
return Promise.resolve(notFound(c)).then((value) => c.toResponse(value));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function callHandler(c, route) {
|
|
297
|
+
const value = route.handler(c.params, c);
|
|
298
|
+
// 同期のハンドラーなら待たない
|
|
299
|
+
return value instanceof Promise ? value.then((v) => respond(c, route, v)) : respond(c, route, value);
|
|
300
|
+
}
|
|
301
|
+
function respond(c, route, value) {
|
|
302
|
+
let res;
|
|
303
|
+
try {
|
|
304
|
+
res = c.toResponse(value);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
// どこが返した値かは、失敗したときだけ組み立てる
|
|
308
|
+
throw markConversionError(err, { kind: "handler", name: `${route.method} ${route.path}` });
|
|
309
|
+
}
|
|
310
|
+
if (c.req.method !== "HEAD")
|
|
311
|
+
return res;
|
|
312
|
+
// HEAD はヘッダーと status だけ返す(ボディは捨てる)
|
|
313
|
+
return (async () => {
|
|
314
|
+
await res.body?.cancel();
|
|
315
|
+
return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
316
|
+
})();
|
|
317
|
+
}
|
|
318
|
+
// 開発モードの fetch。層ごとの時間を測り、エラーページ・Server-Timing・ダッシュボードの記録を付ける
|
|
319
|
+
async function devFetch(req, info, ctx) {
|
|
320
|
+
// 計測の基準になるリクエストの開始時刻
|
|
321
|
+
const origin = performance.now();
|
|
322
|
+
const time = Date.now();
|
|
323
|
+
const c = new Context(req, info, ctx);
|
|
324
|
+
const dashboard = dashboardPromise ? await dashboardPromise : null;
|
|
325
|
+
// /_senri 以下はダッシュボード。localhost から開いたときだけ、ミドルウェアを通さずに処理する
|
|
326
|
+
// (開けないときは普通の処理に回すので、/_senri はないものとして 404 になる)。どちらも記録はしない
|
|
327
|
+
const dashboardPath = underDashboard(c);
|
|
328
|
+
if (dashboard && dashboardPath && dashboard.canOpen(req, info)) {
|
|
329
|
+
return dashboard.handle(req, dashboardPath);
|
|
330
|
+
}
|
|
331
|
+
if (!dashboardPath)
|
|
332
|
+
dashboard?.begin(c, info);
|
|
333
|
+
const fail = (err) => handleError(err, c, onError);
|
|
334
|
+
// ミドルウェアより先にマッチして、c.route / c.params を見られるようにしておく
|
|
335
|
+
const resolved = resolve(c);
|
|
336
|
+
if (resolved.kind === "route") {
|
|
337
|
+
c.route = resolved.route.path;
|
|
338
|
+
c.params = resolved.params;
|
|
339
|
+
}
|
|
340
|
+
// ハンドラー・notFound・toResponse のどこで投げても、compose が onError に集める
|
|
341
|
+
const layers = applicableMiddlewares(c);
|
|
342
|
+
const innerName = INNER_NAME[resolved.kind];
|
|
343
|
+
const measure = { timings: c.timings, origin, innerName };
|
|
344
|
+
const res = await compose(layers, c, () => inner(c, resolved), fail, measure);
|
|
345
|
+
// ミドルウェアを全部通ったあとなので、ここなら「通った順番」が揃っている
|
|
346
|
+
const tools = await devTools;
|
|
347
|
+
const page = await tools.finishErrorPage(c, res);
|
|
348
|
+
// 層ごとの時間を Server-Timing に載せる(ブラウザの開発者ツールで見られる)
|
|
349
|
+
const desc = resolved.kind === "route" ? `${resolved.route.method} ${resolved.route.path}` : innerName;
|
|
350
|
+
const final = tools.withServerTiming(c.finalize(page), tools.serverTimingHeader(c.timings, { name: innerName, desc }, performance.now() - origin));
|
|
351
|
+
// ダッシュボードに記録する(失敗してもレスポンスには影響しない)
|
|
352
|
+
await dashboard?.record(c, final, time, [
|
|
353
|
+
...layers.map((l) => ({ name: l.name, order: l.order ?? null })),
|
|
354
|
+
{ name: innerName, order: null },
|
|
355
|
+
]);
|
|
356
|
+
return final;
|
|
357
|
+
}
|
|
358
|
+
// ミドルウェアを先に(登録順のまま)、ルートはそのあと。ルートの順番は add() が並べ直すので関係ない
|
|
359
|
+
appEntries.set(app, () => [
|
|
360
|
+
...middlewares.map((m) => ({ kind: "use", path: m.path ?? "/", mw: m.mw })),
|
|
361
|
+
...routes.map((r) => ({ kind: "route", method: r.method, path: r.path, handler: r.handler })),
|
|
362
|
+
]);
|
|
363
|
+
// 開発モードでは、ダッシュボードを作ってアプリに結び付けておく
|
|
364
|
+
// Cloudflare Workers では作らない(接続元が分からず localhost 制限で開けないうえ、
|
|
365
|
+
// リクエストごとに別の isolate で動くことがあり、記録が残らない)。/_senri はふつうの 404 になる
|
|
366
|
+
const dashboardPromise = isWorkers() ? null : devTools?.then((m) => {
|
|
367
|
+
const dashboard = m.createDashboard({
|
|
368
|
+
routes: () => app.routeList(),
|
|
369
|
+
middlewares: () => app.middlewareList(),
|
|
370
|
+
});
|
|
371
|
+
m.registerDashboard(app, dashboard);
|
|
372
|
+
return dashboard;
|
|
373
|
+
});
|
|
374
|
+
return app;
|
|
375
|
+
}
|
package/dist/src/body.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// リクエストのボディを、Content-Type に合わせて読む
|
|
2
|
+
import { HttpError } from "./errors.js";
|
|
3
|
+
function mediaType(req) {
|
|
4
|
+
return (req.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
// フィールド名は送る側が決められるので、プロトタイプのないオブジェクトに入れる
|
|
7
|
+
// (ふつうの {} だと "__proto__" でプロトタイプが差し替わり、"constructor" は継承した関数と混ざる)
|
|
8
|
+
function formToObject(form) {
|
|
9
|
+
const out = Object.create(null);
|
|
10
|
+
for (const [name, value] of form) {
|
|
11
|
+
const prev = out[name];
|
|
12
|
+
if (prev === undefined)
|
|
13
|
+
out[name] = value;
|
|
14
|
+
else
|
|
15
|
+
out[name] = Array.isArray(prev) ? [...prev, value] : [prev, value];
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
// - application/json と、+json で終わるもの → JSON(壊れていたら 400)
|
|
20
|
+
// - application/x-www-form-urlencoded と multipart/form-data → フォーム
|
|
21
|
+
// - text/* → 文字列
|
|
22
|
+
// - それ以外 → ArrayBuffer
|
|
23
|
+
export async function readBody(req) {
|
|
24
|
+
const type = mediaType(req);
|
|
25
|
+
if (type === "application/json" || type.endsWith("+json")) {
|
|
26
|
+
const text = await req.text();
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new HttpError(400, "400 Bad Request: JSON が正しくありません", { cause: err });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (type === "application/x-www-form-urlencoded" || type === "multipart/form-data") {
|
|
35
|
+
try {
|
|
36
|
+
return formToObject(await req.formData());
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new HttpError(400, "400 Bad Request: フォームが正しくありません", { cause: err });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (type.startsWith("text/"))
|
|
43
|
+
return req.text();
|
|
44
|
+
return req.arrayBuffer();
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Context, HandlerResult, Middleware } from "./context.ts";
|
|
2
|
+
import { type ConversionOrigin } from "./errors.ts";
|
|
3
|
+
export declare function toResponseOf(c: Context, value: HandlerResult, origin: ConversionOrigin): Response;
|
|
4
|
+
export type Layer = {
|
|
5
|
+
name: string;
|
|
6
|
+
mw: Middleware;
|
|
7
|
+
order?: number;
|
|
8
|
+
};
|
|
9
|
+
export type Timing = {
|
|
10
|
+
name: string;
|
|
11
|
+
total: number;
|
|
12
|
+
self: number;
|
|
13
|
+
failed: boolean;
|
|
14
|
+
start: number;
|
|
15
|
+
stopped: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type Measure = {
|
|
18
|
+
timings: Timing[];
|
|
19
|
+
origin: number;
|
|
20
|
+
innerName: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function compose(layers: Layer[], c: Context, handle: () => Response | Promise<Response>, onError: (err: unknown) => Promise<Response>, measure?: Measure): Promise<Response>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { markConversionError } from "./errors.js";
|
|
2
|
+
// 返した値をレスポンスにする。失敗したら、どこが返した値だったかをエラーに覚えさせてから投げ直す
|
|
3
|
+
export function toResponseOf(c, value, origin) {
|
|
4
|
+
try {
|
|
5
|
+
return c.toResponse(value);
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
throw markConversionError(err, origin);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// ミドルウェアを順に通して、最後に handle を呼ぶ
|
|
12
|
+
// 例外は(ミドルウェアでもハンドラーでも)onError に渡してレスポンスにしてもらう
|
|
13
|
+
// measure を渡したときだけ、各層の時間を timings[i] に記録する(i = 層の順番、最後がいちばん内側)
|
|
14
|
+
export function compose(layers, c, handle, onError, measure) {
|
|
15
|
+
// 近道: ミドルウェアがなく、計測もしないなら、いちばん内側の処理を呼ぶだけ(下の dispatch と同じ結果になる)
|
|
16
|
+
if (layers.length === 0 && !measure) {
|
|
17
|
+
try {
|
|
18
|
+
return Promise.resolve(handle()).catch(onError);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
return onError(err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const timings = measure?.timings;
|
|
25
|
+
// 計測しないときは時計を読まない
|
|
26
|
+
const now = timings ? () => performance.now() : () => 0;
|
|
27
|
+
// どの層で例外が起きたか(層の番号)
|
|
28
|
+
let failedAt = -1;
|
|
29
|
+
const fail = (err, i) => {
|
|
30
|
+
failedAt = i;
|
|
31
|
+
return onError(err);
|
|
32
|
+
};
|
|
33
|
+
const dispatch = async (i) => {
|
|
34
|
+
const start = now();
|
|
35
|
+
// next() の中(下流)で待っていた時間
|
|
36
|
+
let inner = 0;
|
|
37
|
+
// next() を呼ばずに値を返して終わったか
|
|
38
|
+
let stopped = false;
|
|
39
|
+
try {
|
|
40
|
+
return await step(i, (ms) => (inner += ms), () => (stopped = true));
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (measure) {
|
|
44
|
+
const total = now() - start;
|
|
45
|
+
measure.timings[i] = {
|
|
46
|
+
name: layers[i]?.name ?? measure.innerName,
|
|
47
|
+
total,
|
|
48
|
+
self: total - inner,
|
|
49
|
+
failed: failedAt === i,
|
|
50
|
+
start: start - measure.origin,
|
|
51
|
+
stopped,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const step = async (i, addInner, markStopped) => {
|
|
57
|
+
const layer = layers[i];
|
|
58
|
+
if (!layer) {
|
|
59
|
+
try {
|
|
60
|
+
return await handle();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return fail(err, i);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// next() が返す Promise。呼ばれていなければ undefined
|
|
67
|
+
let downstream;
|
|
68
|
+
let result;
|
|
69
|
+
try {
|
|
70
|
+
result = await layer.mw(c, () => {
|
|
71
|
+
if (downstream) {
|
|
72
|
+
// 2回目を許すと、ハンドラーや後ろのミドルウェアが2回動いてしまう
|
|
73
|
+
throw new Error("next() が2回呼ばれました。1つのミドルウェアで呼べるのは1回だけです");
|
|
74
|
+
}
|
|
75
|
+
const nextStart = now();
|
|
76
|
+
downstream = dispatch(i + 1).finally(() => addInner(now() - nextStart));
|
|
77
|
+
return downstream;
|
|
78
|
+
});
|
|
79
|
+
// toResponse も JSON.stringify で投げることがあるので try の中で
|
|
80
|
+
if (result !== undefined) {
|
|
81
|
+
if (!downstream)
|
|
82
|
+
markStopped();
|
|
83
|
+
return toResponseOf(c, result, { kind: "middleware", name: layer.name });
|
|
84
|
+
}
|
|
85
|
+
// 何も返さず next() 済みなら、下流を使う(await し忘れていても、ここで待つ)
|
|
86
|
+
if (downstream)
|
|
87
|
+
return await downstream;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return fail(err, i);
|
|
91
|
+
}
|
|
92
|
+
return fail(new Error("ミドルウェアが何も返さず、next()もよんでいません"), i);
|
|
93
|
+
};
|
|
94
|
+
return dispatch(0);
|
|
95
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 接続元のアドレスを、app.fetch の2つ目の引数から取り出す(c.ip と、ダッシュボードの localhost 制限で使う)
|
|
2
|
+
//
|
|
3
|
+
// Web 標準の Request には接続元が入っていないので、ランタイムが渡すものから取る:
|
|
4
|
+
// - Bun: Bun.serve({ fetch: app.fetch }) は server を渡す → server.requestIP(req)
|
|
5
|
+
// - Deno: Deno.serve(app.fetch) は info を渡す → info.remoteAddr.hostname
|
|
6
|
+
// - Node: senri/node のアダプターが Deno と同じ形 { remoteAddr: { hostname } } で渡す
|
|
7
|
+
// 型ではなく「形」で見分けるので、Cloudflare Workers の fetch(req, env, ctx) の env のような
|
|
8
|
+
// 別のものが来ても壊れず、「接続元不明」(null)になる
|
|
9
|
+
import { isWorkers } from "./runtime.js";
|
|
10
|
+
export function remoteAddress(req, info) {
|
|
11
|
+
try {
|
|
12
|
+
if (typeof info !== "object" || info === null)
|
|
13
|
+
return null;
|
|
14
|
+
const bun = info;
|
|
15
|
+
if (typeof bun.requestIP === "function") {
|
|
16
|
+
const address = bun.requestIP(req)?.address;
|
|
17
|
+
return typeof address === "string" ? address : null;
|
|
18
|
+
}
|
|
19
|
+
const deno = info;
|
|
20
|
+
const hostname = typeof deno.remoteAddr === "object" && deno.remoteAddr !== null ? deno.remoteAddr.hostname : undefined;
|
|
21
|
+
return typeof hostname === "string" ? hostname : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// プロパティを読んだだけで例外を投げるような値でも、壊れずに「不明」にする
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Bun の server か、Deno の info(Node のアダプターも同じ形)か。そうでなければ Workers の env などほかのもの
|
|
29
|
+
export function isConnectionInfo(info) {
|
|
30
|
+
try {
|
|
31
|
+
if (typeof info !== "object" || info === null)
|
|
32
|
+
return false;
|
|
33
|
+
const value = info;
|
|
34
|
+
return typeof value.requestIP === "function" || (typeof value.remoteAddr === "object" && value.remoteAddr !== null);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// c.ip 用。IPv4 を IPv6 の形で書いたもの(::ffff:192.0.2.1)は IPv4 にそろえる
|
|
41
|
+
// Cloudflare Workers には接続元を渡す引数がないので、Cloudflare が付ける CF-Connecting-IP を読む
|
|
42
|
+
// (Cloudflare を通ったリクエストでは、クライアントが送った同名のヘッダーは Cloudflare が上書きする)
|
|
43
|
+
export function clientIp(req, info) {
|
|
44
|
+
const address = remoteAddress(req, info) ?? (isWorkers() ? req.headers.get("cf-connecting-ip") : null);
|
|
45
|
+
if (address === null)
|
|
46
|
+
return null;
|
|
47
|
+
const lower = address.toLowerCase();
|
|
48
|
+
return lower.startsWith("::ffff:") && lower.includes(".") ? address.slice("::ffff:".length) : address;
|
|
49
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Timing } from "./compose.ts";
|
|
2
|
+
import { type CookieOptions } from "./cookie.ts";
|
|
3
|
+
export type BodyValue = Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer> | ReadableStream | FormData | URLSearchParams;
|
|
4
|
+
export type JsonObject = object & {
|
|
5
|
+
call?: never;
|
|
6
|
+
then?: never;
|
|
7
|
+
};
|
|
8
|
+
export type HandlerResult = Response | string | BodyValue | JsonObject;
|
|
9
|
+
export type ExecutionContext = {
|
|
10
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
11
|
+
passThroughOnException(): void;
|
|
12
|
+
};
|
|
13
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
14
|
+
type EnvForm = {
|
|
15
|
+
Bindings: object;
|
|
16
|
+
} | {
|
|
17
|
+
Variables: object;
|
|
18
|
+
};
|
|
19
|
+
export type VariablesOf<E> = IsAny<E> extends true ? any : E extends EnvForm ? (E extends {
|
|
20
|
+
Variables: infer V extends object;
|
|
21
|
+
} ? V : Record<string, unknown>) : E;
|
|
22
|
+
export type BindingsOf<E> = IsAny<E> extends true ? any : E extends {
|
|
23
|
+
Bindings: infer B extends object;
|
|
24
|
+
} ? B : unknown;
|
|
25
|
+
export type Handler<P = Record<string, string>, V extends object = any, I extends object = {}> = (params: P, c: Context<V, I>) => HandlerResult | Promise<HandlerResult>;
|
|
26
|
+
export type Next = () => Promise<Response>;
|
|
27
|
+
export type Middleware<V extends object = any> = (c: Context<V>, next: Next) => HandlerResult | void | Promise<HandlerResult | void>;
|
|
28
|
+
export declare function setValidated(c: Context, target: string, value: unknown): void;
|
|
29
|
+
export declare class Context<V extends object = any, I extends object = {}> {
|
|
30
|
+
#private;
|
|
31
|
+
req: Request;
|
|
32
|
+
error: unknown;
|
|
33
|
+
route: string | undefined;
|
|
34
|
+
params: Record<string, string>;
|
|
35
|
+
constructor(req: Request, info?: unknown, ctx?: ExecutionContext);
|
|
36
|
+
get env(): BindingsOf<V>;
|
|
37
|
+
get executionCtx(): ExecutionContext;
|
|
38
|
+
get ip(): string | null;
|
|
39
|
+
get segments(): readonly string[];
|
|
40
|
+
get path(): string;
|
|
41
|
+
get timings(): Timing[];
|
|
42
|
+
valid<K extends keyof I & string>(target: K): I[K];
|
|
43
|
+
set<K extends keyof VariablesOf<V> & string>(key: K, value: VariablesOf<V>[K]): this;
|
|
44
|
+
get<K extends keyof VariablesOf<V> & string>(key: K): VariablesOf<V>[K] | undefined;
|
|
45
|
+
query(): Record<string, string>;
|
|
46
|
+
query(name: string): string | undefined;
|
|
47
|
+
queries(name: string): string[];
|
|
48
|
+
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
|
+
status(code: number): this;
|
|
54
|
+
header(name: string, value: string): this;
|
|
55
|
+
json(data: unknown, status?: number): Response;
|
|
56
|
+
text(body: string, status?: number): Response;
|
|
57
|
+
finalize(res: Response): Response;
|
|
58
|
+
toResponse(value: HandlerResult): Response;
|
|
59
|
+
}
|
|
60
|
+
export {};
|