@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.
@@ -1,5 +1,3 @@
1
- import { suggest } from "./dev/suggest.js";
2
- import { wantsHtml } from "./dev/render.js";
3
1
  // status 付きで投げられるエラー。message はそのままクライアントに見せる前提のもの
4
2
  export class HttpError extends Error {
5
3
  status;
@@ -43,44 +41,28 @@ export async function handleError(err, c, onError) {
43
41
  return new Response("500 Internal Server Error", { status: 500 });
44
42
  }
45
43
  }
46
- // 本番では、エラーの中身(DB のパスワードなど)や、ルートの一覧がもれないよう固定の文言だけ返す
47
- // dev: true のときだけ、開発者向けの詳しい情報を足す
48
- // validate.ts を import すると循環するので、形で見分ける
49
- function isValidationError(err) {
50
- return err.name === "ValidationError" && typeof err.describe === "function";
44
+ export function senriError(code, error) {
45
+ return Object.assign(typeof error === "string" ? new Error(error) : error, { code });
51
46
  }
52
- export function defaultOnError(dev) {
53
- return (err, c) => {
54
- if (err instanceof HttpError) {
55
- // バリデーションのエラーは、開発モードでだけ、どこが違うかを返す
56
- if (dev && isValidationError(err))
57
- return c.text(err.describe());
58
- return c.text(err.message);
59
- }
60
- if (!dev) {
61
- return c.text("500 Internal Server Error");
62
- }
63
- return c.text(`500 Internal Server Error: ${err instanceof Error ? err.message : String(err)}`);
64
- };
47
+ // Senri が投げたエラーの code(なければ undefined)
48
+ export function errorCode(err) {
49
+ const code = err?.code;
50
+ return typeof code === "string" ? code : undefined;
65
51
  }
66
- export function methodNotAllowed(c, path, methods, dev) {
67
- const message = dev
68
- ? `405 Method Not Allowed: ${c.req.method} ${path}\n${methods} ならあります`
69
- : "405 Method Not Allowed";
70
- return new HttpError(405, message, { headers: { allow: methods } });
52
+ // 本番の既定。エラーの中身(DB のパスワードなど)がもれないよう、固定の文言だけ返す
53
+ // (開発モードの詳しい文言とエラーページは @senrijs/senri/dev が差し替える)
54
+ export const defaultOnError = (err, c) => c.text(err instanceof HttpError ? err.message : "500 Internal Server Error");
55
+ export const defaultNotFound = (c) => c.text("404 Not Found");
56
+ export function methodNotAllowed(methods) {
57
+ return senriError("method_not_allowed", new HttpError(405, "405 Method Not Allowed", { headers: { allow: methods } }));
71
58
  }
72
59
  // QUERY(RFC 10008)は、検索の条件をボディに入れて送るメソッド。ボディと、その形式(Content-Type)が必須
73
60
  // ハンドラーを呼ぶ前に確かめる。どちらも問題なければ null
74
- export async function checkQueryRequest(c, dev) {
75
- if (await isEmptyBody(c)) {
76
- return new HttpError(400, dev
77
- ? "400 Bad Request: QUERY リクエストにボディがありません\nQUERY は、検索の条件をボディに入れて送るメソッドです"
78
- : "400 Bad Request");
79
- }
61
+ export async function checkQueryRequest(c) {
62
+ if (await isEmptyBody(c))
63
+ return senriError("query_no_body", new HttpError(400, "400 Bad Request"));
80
64
  if (!c.req.headers.get("content-type")) {
81
- return new HttpError(415, dev
82
- ? "415 Unsupported Media Type: QUERY リクエストに Content-Type がありません\nボディの形式を指定してください(例: Content-Type: application/json)"
83
- : "415 Unsupported Media Type");
65
+ return senriError("query_no_type", new HttpError(415, "415 Unsupported Media Type"));
84
66
  }
85
67
  return null;
86
68
  }
@@ -113,24 +95,3 @@ async function isEmptyBody(c) {
113
95
  reader.cancel().catch(() => { });
114
96
  }
115
97
  }
116
- export function defaultNotFound(routes, dev) {
117
- return async (c) => {
118
- if (!dev) {
119
- return c.text("404 Not Found");
120
- }
121
- // ブラウザで開いたときは、ルートの一覧付きの404ページ(HTML)にする
122
- // ページの HTML は大きいので、本番では読み込まないよう、ここで初めて import() する
123
- if (wantsHtml(c.req)) {
124
- const { notFoundPage } = await import("./dev/not-found-page.js");
125
- return notFoundPage(c, routes);
126
- }
127
- // ルートと同じく、デコード・正規化したパスで比べる(notFound に来るのはデコードできたリクエストだけ)
128
- const path = c.path;
129
- let message = `404 Not Found: ${c.req.method} ${path}`;
130
- const closest = suggest(routes, path);
131
- if (closest) {
132
- message += `\nもしかして: ${closest.method} ${closest.path} ?`;
133
- }
134
- return c.text(message);
135
- };
136
- }
@@ -1,7 +1,6 @@
1
1
  import type { Handler, Middleware } from "./context.ts";
2
2
  import { type ExtractParams, type Simplify } from "./router.ts";
3
3
  import { type RouteSchemas, type Validated, type ValidatedParams } from "./validate.ts";
4
- export declare function routeHandler(path: string, a: Handler | RouteSchemas, b: Handler | undefined): Handler;
5
4
  type GroupParams<Prefix extends string, Path extends string> = Simplify<ExtractParams<`${Prefix}/${Path}`>>;
6
5
  type GroupMethod<Prefix extends string, V extends object> = {
7
6
  <Path extends string>(path: Path, handler: Handler<GroupParams<Prefix, Path>, V>): void;
package/dist/src/group.js CHANGED
@@ -1,14 +1,6 @@
1
1
  import { normalizeMethod, splitPattern } from "./router.js";
2
- import { checkSchemas, withValidation } from "./validate.js";
3
- // 2つ目の引数がスキーマなら、確かめてからハンドラーを呼ぶものにする(app.ts と同じ)
4
- export function routeHandler(path, a, b) {
5
- if (typeof a === "function")
6
- return a;
7
- if (typeof b !== "function")
8
- throw new TypeError(`${path}: ハンドラーがありません`);
9
- checkSchemas(path, a);
10
- return withValidation(a, b);
11
- }
2
+ import { routeHandler } from "./validate.js";
3
+ import { senriError } from "./errors.js";
12
4
  const entries = new WeakMap();
13
5
  export function joinPath(...parts) {
14
6
  return "/" + parts.flatMap(splitPattern).join("/");
@@ -17,7 +9,7 @@ export function joinPath(...parts) {
17
9
  export function groupEntries(router) {
18
10
  const list = entries.get(router);
19
11
  if (!list)
20
- throw new TypeError("app.route() には createRouter() で作ったルーターを渡してください");
12
+ throw senriError("not_router", new TypeError("Not a router from createRouter()"));
21
13
  return list("");
22
14
  }
23
15
  // V は c.set / c.get で使う変数の型。アプリと同じものを指定する: createRouter<"/admin", Vars>("/admin")
@@ -34,7 +26,7 @@ export function createRouter(prefix) {
34
26
  const path = typeof pathOrMw === "string" ? pathOrMw : "/";
35
27
  const fn = typeof pathOrMw === "string" ? mw : pathOrMw;
36
28
  if (!fn)
37
- throw new Error(`router.use("${path}", ...) にミドルウェアがありません`);
29
+ throw senriError("no_middleware", `Missing middleware: ${path}`);
38
30
  items.push({ kind: "entry", entry: { kind: "use", path, mw: fn } });
39
31
  },
40
32
  get: method("GET"),
@@ -48,18 +40,18 @@ export function createRouter(prefix) {
48
40
  on: ((methods, path, a, b) => {
49
41
  const list = typeof methods === "string" ? [methods] : methods;
50
42
  if (list.length === 0)
51
- throw new Error(`router.on(..., "${path}") にメソッドがありません`);
43
+ throw senriError("no_method", `Missing method: ${path}`);
52
44
  const handler = routeHandler(path, a, b);
53
45
  for (const m of list) {
54
46
  if (m.toUpperCase() === "ALL")
55
- throw new Error("すべてのメソッドで動かすには all() を使ってください");
47
+ throw senriError("on_all", "Use all() for every method");
56
48
  // 名前の確認(CONNECT などの拒否)は、ここで先にしておく(app.route まで待たない)
57
49
  push(normalizeMethod(m), path, handler);
58
50
  }
59
51
  }),
60
52
  route(child) {
61
53
  if (!entries.has(child))
62
- throw new TypeError("route() には createRouter() で作ったルーターを渡してください");
54
+ throw senriError("not_router", new TypeError("Not a router from createRouter()"));
63
55
  items.push({ kind: "router", router: child });
64
56
  },
65
57
  };
@@ -67,7 +59,7 @@ export function createRouter(prefix) {
67
59
  entries.set(router, (base) => {
68
60
  // ルーターが自分自身を(間接的にでも)含んでいると終わらないので止める
69
61
  if (seen.has(router))
70
- throw new Error(`ルーター ${prefix} が自分自身を含んでいます`);
62
+ throw senriError("router_cycle", `Router ${prefix} contains itself`);
71
63
  seen.add(router);
72
64
  try {
73
65
  const here = joinPath(base, prefix);
@@ -1,11 +1,11 @@
1
- export { createApp, type App, type AppOptions, type MountableApp } from "./app.ts";
1
+ export { createApp, type App, type AppCore, type AppOptions, type DevHooks, type DevInstalled, type MountableApp, type Resolved, } from "./app.ts";
2
2
  export { createRouter, type Router } from "./group.ts";
3
3
  export { Context, type BindingsOf, type ExecutionContext, type Handler, type HandlerResult, type Middleware, type Next, type VariablesOf, } from "./context.ts";
4
4
  export { HttpError, type ErrorHandler, type NotFoundHandler } from "./errors.ts";
5
5
  export { ValidationError, type InferOutput, type RouteSchemas, type StandardIssue, type StandardSchemaV1, type ValidationTarget, } from "./validate.ts";
6
6
  export type { CookieOptions } from "./cookie.ts";
7
7
  export type { FormBody, FormValue } from "./body.ts";
8
- export type { Timing } from "./compose.ts";
8
+ export type { Layer, Timing, Trace } from "./compose.ts";
9
9
  export { logger, showRoutes } from "./logger.ts";
10
10
  export { health, type HealthOptions } from "./health.ts";
11
11
  export { cors, type CorsOptions } from "./middleware/cors.ts";
package/dist/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
- export { createApp } from "./app.js";
1
+ // @senrijs/senri の入口
2
+ // 開発モード(エラーページ・ダッシュボードなど)は @senrijs/senri/dev、Cookie は @senrijs/senri/cookie、接続元の IP は @senrijs/senri/ip
3
+ export { createApp, } from "./app.js";
2
4
  export { createRouter } from "./group.js";
3
5
  export { Context, } from "./context.js";
4
6
  export { HttpError } from "./errors.js";
@@ -0,0 +1,2 @@
1
+ import { type Context } from "./context.ts";
2
+ export declare function getIp(c: Context): string | null;
package/dist/src/ip.js ADDED
@@ -0,0 +1,12 @@
1
+ // @senrijs/senri/ip の入口
2
+ // Context のメソッドにしないのは、使わないアプリのバンドルに入れないため(0.1 の c.ip)
3
+ import { connectionOf } from "./context.js";
4
+ import { clientIp } from "./connection.js";
5
+ // 接続元の IP アドレス。分からなければ null(app.fetch を直接呼んだときなど)
6
+ // - Bun / Deno / Node(senri/node): ランタイムが渡す接続元
7
+ // - Cloudflare Workers: Cloudflare が付ける CF-Connecting-IP
8
+ // X-Forwarded-For などのヘッダーは偽装できるので読まない。プロキシの後ろでは、プロキシが付けるヘッダーを自分で読む
9
+ export function getIp(c) {
10
+ const { req, info } = connectionOf(c);
11
+ return clientIp(req, info);
12
+ }
@@ -1,4 +1,4 @@
1
- import { HttpError } from "./errors.js";
1
+ import { HttpError, senriError } from "./errors.js";
2
2
  // ルートのパターン("/users/:id")を分割する。末尾スラッシュや連続スラッシュは無視
3
3
  export function splitPattern(pattern) {
4
4
  return pattern.split("/").filter(Boolean);
@@ -110,10 +110,10 @@ export function checkParamNames(path, segments) {
110
110
  continue;
111
111
  const name = s.slice(1);
112
112
  if (name === "") {
113
- throw new Error(`パラメーター名が空です: "${path}"`);
113
+ throw senriError("param_empty", `Empty parameter name: ${path}`);
114
114
  }
115
115
  if (seen.has(name)) {
116
- throw new Error(`パラメーター名 "${name}" が2回使われています: "${path}"`);
116
+ throw senriError("param_twice", `Parameter "${name}" used twice: ${path}`);
117
117
  }
118
118
  seen.add(name);
119
119
  }
@@ -125,10 +125,10 @@ const METHOD = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
125
125
  // - "ALL" は app.all() の印として使っているので、app.on() からは登録させない
126
126
  export function normalizeMethod(method) {
127
127
  if (!METHOD.test(method))
128
- throw new Error(`メソッド名に使えない文字があります: ${JSON.stringify(method)}`);
128
+ throw senriError("method_invalid", `Invalid method: ${JSON.stringify(method)}`);
129
129
  const upper = method.toUpperCase();
130
130
  if (upper === "CONNECT" || upper === "TRACE" || upper === "TRACK") {
131
- throw new Error(`${upper} メソッドは扱いません(Senri では CONNECT / TRACE / TRACK を登録できません)`);
131
+ throw senriError("method_unsupported", `${upper} is not supported`);
132
132
  }
133
133
  return upper;
134
134
  }
@@ -2,3 +2,4 @@ export declare function isWorkers(): boolean;
2
2
  export declare function cwd(): string | null;
3
3
  export declare function env(name: string): string | undefined;
4
4
  export declare function canRead(path: string): boolean;
5
+ export declare function canReadEnv(name: string): boolean;
@@ -47,3 +47,9 @@ export function canRead(path) {
47
47
  return denoAllows({ name: "read", path });
48
48
  return true;
49
49
  }
50
+ // 環境変数を読めるか。Deno で許可(--allow-env)がないときは false(聞き返しはしない)
51
+ export function canReadEnv(name) {
52
+ if (g.Deno)
53
+ return denoAllows({ name: "env", variable: name });
54
+ return true;
55
+ }
@@ -40,8 +40,7 @@ export declare class ValidationError extends HttpError {
40
40
  readonly target: ValidationTarget;
41
41
  readonly issues: readonly StandardIssue[];
42
42
  constructor(target: ValidationTarget, issues: readonly StandardIssue[]);
43
- describe(): string;
44
43
  }
45
- export declare function checkSchemas(path: string, schemas: RouteSchemas): void;
46
44
  export declare function withValidation(schemas: RouteSchemas, handler: Handler): Handler;
45
+ export declare function routeHandler(path: string, a: Handler | RouteSchemas, b: Handler | undefined): Handler;
47
46
  export {};
@@ -7,9 +7,10 @@
7
7
  // - body: c.body() の結果(Content-Type に合わせて読んだもの)。通れば c.valid("body") で読める
8
8
  // どれかが通らなければ、ハンドラーは呼ばずに 400 にする(ValidationError)
9
9
  import { setValidated } from "./context.js";
10
- import { HttpError } from "./errors.js";
10
+ import { HttpError, senriError } from "./errors.js";
11
11
  // バリデーションに通らなかった。issues はスキーマのライブラリが返したもの
12
12
  // app.onError で受けて、自分の形(JSON など)で返すこともできる
13
+ // (開発モードの「どこが違うか」の一覧は @senrijs/senri/dev が組み立てる)
13
14
  export class ValidationError extends HttpError {
14
15
  target;
15
16
  issues;
@@ -20,49 +21,6 @@ export class ValidationError extends HttpError {
20
21
  this.target = target;
21
22
  this.issues = issues;
22
23
  }
23
- // 開発モードで返す、どこが違うかの一覧
24
- describe() {
25
- const lines = this.issues.map((issue) => {
26
- const path = issuePath(issue);
27
- return `- ${path ? `${path}: ` : ""}${issue.message}`;
28
- });
29
- return [`400 Bad Request: ${LABEL[this.target]}が、スキーマに合いません`, ...lines].join("\n");
30
- }
31
- }
32
- const LABEL = {
33
- params: "パスパラメーター",
34
- query: "クエリ",
35
- body: "ボディ",
36
- };
37
- // ["user", 0, "name"] → "user[0].name"
38
- function issuePath(issue) {
39
- let out = "";
40
- for (const part of issue.path ?? []) {
41
- const key = typeof part === "object" ? part.key : part;
42
- if (typeof key === "number")
43
- out += `[${key}]`;
44
- else
45
- out += out ? `.${String(key)}` : String(key);
46
- }
47
- return out;
48
- }
49
- // ArkType のスキーマは関数なので、オブジェクトでも関数でもよい
50
- function isSchema(value) {
51
- return (typeof value === "object" || typeof value === "function") && value !== null && "~standard" in value;
52
- }
53
- // スキーマを確かめておく(書き間違いに、登録した時点で気づけるように)
54
- export function checkSchemas(path, schemas) {
55
- if (typeof schemas !== "object" || schemas === null) {
56
- throw new TypeError(`${path}: 2つ目の引数には { params, query, body } のスキーマかハンドラーを渡してください`);
57
- }
58
- for (const [key, schema] of Object.entries(schemas)) {
59
- if (key !== "params" && key !== "query" && key !== "body") {
60
- throw new TypeError(`${path}: スキーマに渡せるのは params / query / body だけです("${key}" は使えません)`);
61
- }
62
- if (schema !== undefined && !isSchema(schema)) {
63
- throw new TypeError(`${path}: ${key} が Standard Schema のスキーマではありません(Zod・Valibot・ArkType などのスキーマを渡してください)`);
64
- }
65
- }
66
24
  }
67
25
  async function run(schema, target, value) {
68
26
  const result = await schema["~standard"].validate(value);
@@ -85,3 +43,13 @@ export function withValidation(schemas, handler) {
85
43
  return handler(validParams, c);
86
44
  };
87
45
  }
46
+ // app.get(path, handler) と app.get(path, { params, query, body }, handler) のどちらか
47
+ // 2つ目の引数がスキーマなら、確かめてからハンドラーを呼ぶものにする
48
+ // (スキーマの書き間違いは、開発モードで登録した時点で知らせる。@senrijs/senri/dev の validation.ts)
49
+ export function routeHandler(path, a, b) {
50
+ if (typeof a === "function")
51
+ return a;
52
+ if (typeof b !== "function")
53
+ throw senriError("no_handler", `Missing handler: ${path}`);
54
+ return withValidation(a, b);
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senrijs/senri",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Web 標準の Request / Response で書かれた、小さな Web フレームワーク。Bun・Deno・Node.js・Cloudflare Workers で動く",
5
5
  "license": "MIT",
6
6
  "author": "ibuki-hum4",
@@ -35,6 +35,18 @@
35
35
  "types": "./dist/src/index.d.ts",
36
36
  "import": "./dist/src/index.js"
37
37
  },
38
+ "./dev": {
39
+ "types": "./dist/src/dev/index.d.ts",
40
+ "import": "./dist/src/dev/index.js"
41
+ },
42
+ "./cookie": {
43
+ "types": "./dist/src/cookie.d.ts",
44
+ "import": "./dist/src/cookie.js"
45
+ },
46
+ "./ip": {
47
+ "types": "./dist/src/ip.d.ts",
48
+ "import": "./dist/src/ip.js"
49
+ },
38
50
  "./node": {
39
51
  "types": "./dist/adapters/node/index.d.ts",
40
52
  "import": "./dist/adapters/node/index.js"
@@ -54,23 +66,27 @@
54
66
  "scripts": {
55
67
  "build": "tsc -p tsconfig.build.json",
56
68
  "build:pages": "vite build -c ui/vite.config.ts && node scripts/build-pages.ts",
57
- "typecheck": "bun run build && bun check && bun check -p test && bun check -p test/node && bun check -p adapters/node && bun check -p examples/bun && bun check -p examples/node && bun check -p scripts && deno check examples/deno/index.ts && bun check -p adapters/serve && bun check -p adapters/static && bun check -p ui",
69
+ "typecheck": "bun run build && bun check && bun check -p test && bun check -p test/exact && bun check -p test/node && bun check -p adapters/node && bun check -p examples/bun && bun check -p examples/node && bun check -p scripts && deno check examples/deno/index.ts && bun check -p adapters/serve && bun check -p adapters/static && bun check -p ui",
58
70
  "dev": "bun --watch examples/bun/index.ts",
59
71
  "test": "bun test",
60
72
  "test:node": "bun run build && node --test --test-timeout=5000 \"test/node/**/*.test.ts\"",
61
- "smoke": "node scripts/smoke.ts"
73
+ "smoke": "node scripts/smoke.ts",
74
+ "browser": "node scripts/browser.ts",
75
+ "size": "bun scripts/size.ts"
62
76
  },
63
77
  "devDependencies": {
64
78
  "@types/bun": "latest",
65
79
  "@types/node": "^26.6.5",
66
80
  "@vitejs/plugin-vue": "6.0.10",
67
81
  "arktype": "2.2.8",
82
+ "playwright": "1.64.0",
68
83
  "typescript": "^7",
69
84
  "valibot": "1.5.0",
70
85
  "vite": "8.3.5",
71
86
  "vite-plugin-singlefile": "2.3.4",
72
87
  "vue": "3.5.44",
73
88
  "workerd": "1.20261010.1",
89
+ "wrangler": "4.149.0",
74
90
  "zod": "4.6.6"
75
91
  }
76
92
  }
@@ -1,3 +0,0 @@
1
- export declare function cwd(): string | null;
2
- export declare function env(name: string): string | undefined;
3
- export declare function canRead(path: string): boolean;
@@ -1,45 +0,0 @@
1
- // 開発用ページで使う、ランタイムごとに違う処理(カレントディレクトリ、環境変数、ファイルの読み込み権限)
2
- //
3
- // Deno は、許可されていない環境変数やファイルを読もうとすると、ターミナルで「許可しますか?」と聞いてきて
4
- // その間リクエストが止まってしまう。なので先に permissions.querySync() で「許可済みか」だけを確かめ、
5
- // 許可されていなければ読まずにあきらめる(聞き返しはしない)
6
- const g = globalThis;
7
- function denoAllows(desc) {
8
- try {
9
- return g.Deno.permissions.querySync(desc).state === "granted";
10
- }
11
- catch {
12
- return false;
13
- }
14
- }
15
- // スラッシュ区切りのカレントディレクトリ。分からなければ null
16
- export function cwd() {
17
- try {
18
- // Deno 2 には process もあるが、権限の確認をしたいので Deno を先に見る
19
- // 全体({ name: "read" })で聞くと、--allow-read=. のように範囲を絞ったときに "prompt" になってしまう。
20
- // カレントディレクトリが読めればよいので、"." で聞く
21
- if (g.Deno)
22
- return denoAllows({ name: "read", path: "." }) ? g.Deno.cwd().replaceAll("\\", "/") : null;
23
- if (g.process)
24
- return g.process.cwd().replaceAll("\\", "/");
25
- }
26
- catch {
27
- // 読めなければあきらめる
28
- }
29
- return null;
30
- }
31
- export function env(name) {
32
- try {
33
- if (g.Deno)
34
- return denoAllows({ name: "env", variable: name }) ? g.Deno.env.get(name) : undefined;
35
- return g.process?.env[name];
36
- }
37
- catch {
38
- return undefined;
39
- }
40
- }
41
- export function canRead(path) {
42
- if (g.Deno)
43
- return denoAllows({ name: "read", path });
44
- return true;
45
- }