@shaferllc/keel 0.12.0 → 0.35.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 +28 -3
- package/dist/core/application.js +9 -0
- package/dist/core/auth.d.ts +41 -0
- package/dist/core/auth.js +70 -0
- package/dist/core/cache.d.ts +41 -0
- package/dist/core/cache.js +81 -0
- package/dist/core/casts.d.ts +19 -0
- package/dist/core/casts.js +69 -0
- package/dist/core/crypto.d.ts +24 -0
- package/dist/core/crypto.js +97 -0
- package/dist/core/database.d.ts +58 -0
- package/dist/core/database.js +140 -0
- package/dist/core/debug.d.ts +12 -0
- package/dist/core/debug.js +55 -0
- package/dist/core/events.d.ts +21 -0
- package/dist/core/events.js +46 -0
- package/dist/core/exceptions.d.ts +10 -1
- package/dist/core/exceptions.js +10 -1
- package/dist/core/factory.d.ts +84 -0
- package/dist/core/factory.js +154 -0
- package/dist/core/helpers.d.ts +13 -0
- package/dist/core/helpers.js +24 -0
- package/dist/core/http/kernel.js +21 -2
- package/dist/core/http/router.d.ts +32 -8
- package/dist/core/http/router.js +74 -5
- package/dist/core/index.d.ts +31 -2
- package/dist/core/index.js +17 -1
- package/dist/core/logger.d.ts +29 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/mail.d.ts +94 -0
- package/dist/core/mail.js +157 -0
- package/dist/core/migrations.d.ts +76 -0
- package/dist/core/migrations.js +211 -0
- package/dist/core/model.d.ts +75 -0
- package/dist/core/model.js +202 -0
- package/dist/core/queue.d.ts +73 -0
- package/dist/core/queue.js +88 -0
- package/dist/core/rate-limit.d.ts +23 -0
- package/dist/core/rate-limit.js +50 -0
- package/dist/core/relations.d.ts +90 -0
- package/dist/core/relations.js +229 -0
- package/dist/core/request.d.ts +53 -1
- package/dist/core/request.js +187 -0
- package/dist/core/session.d.ts +46 -0
- package/dist/core/session.js +112 -0
- package/dist/core/static.d.ts +27 -0
- package/dist/core/static.js +61 -0
- package/package.json +1 -1
package/dist/core/request.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* kernel enables for every request. They only work inside a request.
|
|
13
13
|
*/
|
|
14
14
|
import { getContext } from "hono/context-storage";
|
|
15
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
16
|
+
import { HttpException } from "./exceptions.js";
|
|
15
17
|
/** The current request context. Throws if called outside a request. */
|
|
16
18
|
export function ctx() {
|
|
17
19
|
return getContext();
|
|
@@ -25,6 +27,46 @@ function maybeCtx() {
|
|
|
25
27
|
return undefined;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
30
|
+
/** Parse the FormData once per request (web-standard, edge-safe). */
|
|
31
|
+
const FORM_CACHE = new WeakMap();
|
|
32
|
+
async function cachedFormData() {
|
|
33
|
+
const raw = ctx().req.raw;
|
|
34
|
+
if (FORM_CACHE.has(raw))
|
|
35
|
+
return FORM_CACHE.get(raw);
|
|
36
|
+
let fd = null;
|
|
37
|
+
try {
|
|
38
|
+
fd = await raw.formData();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
fd = null;
|
|
42
|
+
}
|
|
43
|
+
FORM_CACHE.set(raw, fd);
|
|
44
|
+
return fd;
|
|
45
|
+
}
|
|
46
|
+
/** Parse an Accept-style header into values ordered by q-weight. */
|
|
47
|
+
function parseAccept(header) {
|
|
48
|
+
if (!header)
|
|
49
|
+
return [];
|
|
50
|
+
return header
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((part) => {
|
|
53
|
+
const [value, ...params] = part.trim().split(";");
|
|
54
|
+
const q = params.find((p) => p.trim().startsWith("q="));
|
|
55
|
+
return { value: value.trim(), q: q ? parseFloat(q.split("=")[1]) : 1 };
|
|
56
|
+
})
|
|
57
|
+
.filter((x) => x.q > 0)
|
|
58
|
+
.sort((a, b) => b.q - a.q)
|
|
59
|
+
.map((x) => x.value);
|
|
60
|
+
}
|
|
61
|
+
function negotiate(headerName, offered) {
|
|
62
|
+
const accepted = parseAccept(ctx().req.header(headerName));
|
|
63
|
+
if (accepted.includes("*/*") || accepted.includes("*"))
|
|
64
|
+
return offered[0] ?? null;
|
|
65
|
+
for (const a of accepted)
|
|
66
|
+
if (offered.includes(a))
|
|
67
|
+
return a;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
28
70
|
/* ------------------------------ responses ------------------------------ */
|
|
29
71
|
/* These work inside a handler AND standalone (e.g. as a static route value).
|
|
30
72
|
* Inside a request they build on the context (merging any headers); outside a
|
|
@@ -73,6 +115,11 @@ export const response = {
|
|
|
73
115
|
redirect(location, status) {
|
|
74
116
|
return redirect(location, status);
|
|
75
117
|
},
|
|
118
|
+
send(data, status) {
|
|
119
|
+
return typeof data === "object" && data !== null
|
|
120
|
+
? json(data, status)
|
|
121
|
+
: text(String(data), status);
|
|
122
|
+
},
|
|
76
123
|
status(code) {
|
|
77
124
|
ctx().status(code);
|
|
78
125
|
return response;
|
|
@@ -81,6 +128,37 @@ export const response = {
|
|
|
81
128
|
ctx().header(name, value);
|
|
82
129
|
return response;
|
|
83
130
|
},
|
|
131
|
+
type(mime) {
|
|
132
|
+
ctx().header("content-type", mime);
|
|
133
|
+
return response;
|
|
134
|
+
},
|
|
135
|
+
append(name, value) {
|
|
136
|
+
ctx().header(name, value, { append: true });
|
|
137
|
+
return response;
|
|
138
|
+
},
|
|
139
|
+
removeHeader(name) {
|
|
140
|
+
ctx().res.headers.delete(name);
|
|
141
|
+
return response;
|
|
142
|
+
},
|
|
143
|
+
cookie(name, value, options) {
|
|
144
|
+
setCookie(ctx(), name, value, options);
|
|
145
|
+
return response;
|
|
146
|
+
},
|
|
147
|
+
clearCookie(name, options) {
|
|
148
|
+
deleteCookie(ctx(), name, options);
|
|
149
|
+
return response;
|
|
150
|
+
},
|
|
151
|
+
abort(message, status = 400) {
|
|
152
|
+
throw new HttpException(status, message);
|
|
153
|
+
},
|
|
154
|
+
abortIf(condition, message, status = 400) {
|
|
155
|
+
if (condition)
|
|
156
|
+
throw new HttpException(status, message);
|
|
157
|
+
},
|
|
158
|
+
abortUnless(condition, message, status = 400) {
|
|
159
|
+
if (!condition)
|
|
160
|
+
throw new HttpException(status, message);
|
|
161
|
+
},
|
|
84
162
|
};
|
|
85
163
|
/* ---------------------------- request access --------------------------- */
|
|
86
164
|
/**
|
|
@@ -134,6 +212,115 @@ export const request = {
|
|
|
134
212
|
subdomain(name) {
|
|
135
213
|
return ctx().get("subdomains")?.[name];
|
|
136
214
|
},
|
|
215
|
+
/** A request cookie by name, or all cookies when called with no argument. */
|
|
216
|
+
cookie(name) {
|
|
217
|
+
return name ? getCookie(ctx(), name) : getCookie(ctx());
|
|
218
|
+
},
|
|
219
|
+
/** The client IP, from X-Forwarded-For / X-Real-IP. */
|
|
220
|
+
ip() {
|
|
221
|
+
const c = ctx();
|
|
222
|
+
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
223
|
+
c.req.header("x-real-ip") ??
|
|
224
|
+
undefined);
|
|
225
|
+
},
|
|
226
|
+
/** All inputs — query string merged with the parsed body (async). */
|
|
227
|
+
async all() {
|
|
228
|
+
const c = ctx();
|
|
229
|
+
const query = c.req.query();
|
|
230
|
+
const body = {};
|
|
231
|
+
const ct = c.req.header("content-type") ?? "";
|
|
232
|
+
try {
|
|
233
|
+
if (ct.includes("application/json")) {
|
|
234
|
+
Object.assign(body, await c.req.json());
|
|
235
|
+
}
|
|
236
|
+
else if (ct.includes("form")) {
|
|
237
|
+
const fd = await cachedFormData();
|
|
238
|
+
if (fd)
|
|
239
|
+
for (const [k, v] of fd.entries())
|
|
240
|
+
if (!(v instanceof File))
|
|
241
|
+
body[k] = v;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* no or invalid body — ignore */
|
|
246
|
+
}
|
|
247
|
+
return { ...query, ...body };
|
|
248
|
+
},
|
|
249
|
+
/** An uploaded file by field name (a web `File`), or undefined. */
|
|
250
|
+
async file(name) {
|
|
251
|
+
const fd = await cachedFormData();
|
|
252
|
+
const value = fd?.get(name);
|
|
253
|
+
return value instanceof File ? value : undefined;
|
|
254
|
+
},
|
|
255
|
+
/** All uploaded files for a field name. */
|
|
256
|
+
async files(name) {
|
|
257
|
+
const fd = await cachedFormData();
|
|
258
|
+
return (fd?.getAll(name) ?? []).filter((v) => v instanceof File);
|
|
259
|
+
},
|
|
260
|
+
/** Every uploaded file, grouped by field name. */
|
|
261
|
+
async allFiles() {
|
|
262
|
+
const fd = await cachedFormData();
|
|
263
|
+
const out = {};
|
|
264
|
+
if (!fd)
|
|
265
|
+
return out;
|
|
266
|
+
for (const [key, value] of fd.entries()) {
|
|
267
|
+
if (!(value instanceof File))
|
|
268
|
+
continue;
|
|
269
|
+
const existing = out[key];
|
|
270
|
+
if (existing === undefined)
|
|
271
|
+
out[key] = value;
|
|
272
|
+
else if (Array.isArray(existing))
|
|
273
|
+
existing.push(value);
|
|
274
|
+
else
|
|
275
|
+
out[key] = [existing, value];
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
},
|
|
279
|
+
/** True if the request declares a body. */
|
|
280
|
+
hasBody() {
|
|
281
|
+
const c = ctx();
|
|
282
|
+
return !!(c.req.header("content-length") || c.req.header("transfer-encoding"));
|
|
283
|
+
},
|
|
284
|
+
/** All request headers as an object. */
|
|
285
|
+
headers() {
|
|
286
|
+
return Object.fromEntries(ctx().req.raw.headers);
|
|
287
|
+
},
|
|
288
|
+
/** The full X-Forwarded-For chain (client first). */
|
|
289
|
+
ips() {
|
|
290
|
+
const xff = ctx().req.header("x-forwarded-for");
|
|
291
|
+
return xff ? xff.split(",").map((s) => s.trim()) : [];
|
|
292
|
+
},
|
|
293
|
+
/** The best of the offered content types per the Accept header, or null. */
|
|
294
|
+
accepts(types) {
|
|
295
|
+
return negotiate("accept", types);
|
|
296
|
+
},
|
|
297
|
+
/** Accepted content types, ordered by preference. */
|
|
298
|
+
types() {
|
|
299
|
+
return parseAccept(ctx().req.header("accept"));
|
|
300
|
+
},
|
|
301
|
+
/** The best of the offered languages per Accept-Language, or null. */
|
|
302
|
+
language(languages) {
|
|
303
|
+
return negotiate("accept-language", languages);
|
|
304
|
+
},
|
|
305
|
+
/** Accepted languages, ordered by preference. */
|
|
306
|
+
languages() {
|
|
307
|
+
return parseAccept(ctx().req.header("accept-language"));
|
|
308
|
+
},
|
|
309
|
+
/** A single input (from query or body), with an optional fallback (async). */
|
|
310
|
+
async input(key, fallback) {
|
|
311
|
+
const all = await this.all();
|
|
312
|
+
return (key in all ? all[key] : fallback);
|
|
313
|
+
},
|
|
314
|
+
/** Only the named inputs (async). */
|
|
315
|
+
async only(keys) {
|
|
316
|
+
const all = await this.all();
|
|
317
|
+
return Object.fromEntries(keys.filter((k) => k in all).map((k) => [k, all[k]]));
|
|
318
|
+
},
|
|
319
|
+
/** Every input except the named ones (async). */
|
|
320
|
+
async except(keys) {
|
|
321
|
+
const all = await this.all();
|
|
322
|
+
return Object.fromEntries(Object.entries(all).filter(([k]) => !keys.includes(k)));
|
|
323
|
+
},
|
|
137
324
|
};
|
|
138
325
|
export function param(name) {
|
|
139
326
|
const c = ctx();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions. A cookie-backed session store — no external service, so it works
|
|
3
|
+
* the same on Node and the edge. Install `sessionMiddleware()` in your HTTP
|
|
4
|
+
* kernel, then reach the session anywhere with `session()`.
|
|
5
|
+
*
|
|
6
|
+
* session().put("userId", user.id);
|
|
7
|
+
* const id = session().get("userId");
|
|
8
|
+
* session().flash("status", "Saved!"); // available on the next request
|
|
9
|
+
*/
|
|
10
|
+
import type { MiddlewareHandler } from "hono";
|
|
11
|
+
import { setCookie } from "hono/cookie";
|
|
12
|
+
type SessionData = Record<string, unknown>;
|
|
13
|
+
type CookieOptions = Parameters<typeof setCookie>[3];
|
|
14
|
+
export declare class Session {
|
|
15
|
+
private data;
|
|
16
|
+
constructor(data: SessionData);
|
|
17
|
+
/** Everything in the session (excluding internal flash keys). */
|
|
18
|
+
all(): SessionData;
|
|
19
|
+
get<T = unknown>(key: string, fallback?: T): T;
|
|
20
|
+
put(key: string, value: unknown): this;
|
|
21
|
+
/** Alias for put(). */
|
|
22
|
+
set(key: string, value: unknown): this;
|
|
23
|
+
has(key: string): boolean;
|
|
24
|
+
forget(key: string): this;
|
|
25
|
+
/** Read and remove a value. */
|
|
26
|
+
pull<T = unknown>(key: string, fallback?: T): T;
|
|
27
|
+
increment(key: string, by?: number): this;
|
|
28
|
+
decrement(key: string, by?: number): this;
|
|
29
|
+
clear(): this;
|
|
30
|
+
/** Flash a value for the next request only. */
|
|
31
|
+
flash(key: string, value: unknown): this;
|
|
32
|
+
/** Read a value flashed on the previous request. */
|
|
33
|
+
flashed<T = unknown>(key: string, fallback?: T): T;
|
|
34
|
+
}
|
|
35
|
+
export interface SessionOptions {
|
|
36
|
+
cookieName?: string;
|
|
37
|
+
cookie?: CookieOptions;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Loads the session from its cookie before the request and writes it back
|
|
41
|
+
* after. Register it in your HTTP kernel with `this.use(sessionMiddleware())`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function sessionMiddleware(options?: SessionOptions): MiddlewareHandler;
|
|
44
|
+
/** The current request's session. Requires `sessionMiddleware()` installed. */
|
|
45
|
+
export declare function session(): Session;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions. A cookie-backed session store — no external service, so it works
|
|
3
|
+
* the same on Node and the edge. Install `sessionMiddleware()` in your HTTP
|
|
4
|
+
* kernel, then reach the session anywhere with `session()`.
|
|
5
|
+
*
|
|
6
|
+
* session().put("userId", user.id);
|
|
7
|
+
* const id = session().get("userId");
|
|
8
|
+
* session().flash("status", "Saved!"); // available on the next request
|
|
9
|
+
*/
|
|
10
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
11
|
+
import { ctx } from "./request.js";
|
|
12
|
+
const FLASH = "__flash";
|
|
13
|
+
const OLD = "__old";
|
|
14
|
+
export class Session {
|
|
15
|
+
data;
|
|
16
|
+
constructor(data) {
|
|
17
|
+
this.data = data;
|
|
18
|
+
}
|
|
19
|
+
/** Everything in the session (excluding internal flash keys). */
|
|
20
|
+
all() {
|
|
21
|
+
const { [FLASH]: _f, [OLD]: _o, ...rest } = this.data;
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
get(key, fallback) {
|
|
25
|
+
return (key in this.data ? this.data[key] : fallback);
|
|
26
|
+
}
|
|
27
|
+
put(key, value) {
|
|
28
|
+
this.data[key] = value;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
/** Alias for put(). */
|
|
32
|
+
set(key, value) {
|
|
33
|
+
return this.put(key, value);
|
|
34
|
+
}
|
|
35
|
+
has(key) {
|
|
36
|
+
return key in this.data && this.data[key] != null;
|
|
37
|
+
}
|
|
38
|
+
forget(key) {
|
|
39
|
+
delete this.data[key];
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
/** Read and remove a value. */
|
|
43
|
+
pull(key, fallback) {
|
|
44
|
+
const value = this.get(key, fallback);
|
|
45
|
+
this.forget(key);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
increment(key, by = 1) {
|
|
49
|
+
this.data[key] = (this.data[key] ?? 0) + by;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
decrement(key, by = 1) {
|
|
53
|
+
return this.increment(key, -by);
|
|
54
|
+
}
|
|
55
|
+
clear() {
|
|
56
|
+
for (const key of Object.keys(this.data))
|
|
57
|
+
delete this.data[key];
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/** Flash a value for the next request only. */
|
|
61
|
+
flash(key, value) {
|
|
62
|
+
const flash = this.data[FLASH] ?? {};
|
|
63
|
+
flash[key] = value;
|
|
64
|
+
this.data[FLASH] = flash;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
/** Read a value flashed on the previous request. */
|
|
68
|
+
flashed(key, fallback) {
|
|
69
|
+
const old = this.data[OLD] ?? {};
|
|
70
|
+
return (key in old ? old[key] : fallback);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Loads the session from its cookie before the request and writes it back
|
|
75
|
+
* after. Register it in your HTTP kernel with `this.use(sessionMiddleware())`.
|
|
76
|
+
*/
|
|
77
|
+
export function sessionMiddleware(options = {}) {
|
|
78
|
+
const name = options.cookieName ?? "keel_session";
|
|
79
|
+
return async (c, next) => {
|
|
80
|
+
let data = {};
|
|
81
|
+
const raw = getCookie(c, name);
|
|
82
|
+
if (raw) {
|
|
83
|
+
try {
|
|
84
|
+
data = JSON.parse(atob(raw));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* tampered/expired — start fresh */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Rotate flash: last request's flash becomes this request's "old".
|
|
91
|
+
data[OLD] = data[FLASH] ?? {};
|
|
92
|
+
delete data[FLASH];
|
|
93
|
+
c.set("session", data);
|
|
94
|
+
await next();
|
|
95
|
+
const toStore = { ...data };
|
|
96
|
+
delete toStore[OLD];
|
|
97
|
+
setCookie(c, name, btoa(JSON.stringify(toStore)), {
|
|
98
|
+
httpOnly: true,
|
|
99
|
+
path: "/",
|
|
100
|
+
sameSite: "Lax",
|
|
101
|
+
...options.cookie,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** The current request's session. Requires `sessionMiddleware()` installed. */
|
|
106
|
+
export function session() {
|
|
107
|
+
const data = ctx().get("session");
|
|
108
|
+
if (!data) {
|
|
109
|
+
throw new Error("Session is not available. Add sessionMiddleware() to your HTTP kernel.");
|
|
110
|
+
}
|
|
111
|
+
return new Session(data);
|
|
112
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A static file server middleware. Serves files from a directory (default
|
|
3
|
+
* `public/`) before your routes run — if a file matches the request path it's
|
|
4
|
+
* sent with caching headers; otherwise the request continues to your routes.
|
|
5
|
+
*
|
|
6
|
+
* `node:fs` is imported dynamically, so the core still loads on the edge; add
|
|
7
|
+
* this middleware only in Node apps (on Workers, serve assets via the platform).
|
|
8
|
+
*
|
|
9
|
+
* this.use(serveStatic()); // ./public
|
|
10
|
+
* this.use(serveStatic({ root: "./assets", maxAge: 86400, immutable: true }));
|
|
11
|
+
*/
|
|
12
|
+
import type { MiddlewareHandler } from "hono";
|
|
13
|
+
export interface StaticOptions {
|
|
14
|
+
/** Directory to serve from. Default: "./public". */
|
|
15
|
+
root?: string;
|
|
16
|
+
/** Index file for directory requests. Default: "index.html". */
|
|
17
|
+
index?: string;
|
|
18
|
+
/** Dot-file policy: "ignore" (404 → next), "deny" (403), "allow". Default: "ignore". */
|
|
19
|
+
dotFiles?: "ignore" | "deny" | "allow";
|
|
20
|
+
/** Cache-Control max-age in seconds. Omit for no Cache-Control header. */
|
|
21
|
+
maxAge?: number;
|
|
22
|
+
/** Add the `immutable` Cache-Control directive (for hashed filenames). */
|
|
23
|
+
immutable?: boolean;
|
|
24
|
+
/** Extra headers per file. */
|
|
25
|
+
headers?: (path: string) => Record<string, string> | undefined;
|
|
26
|
+
}
|
|
27
|
+
export declare function serveStatic(options?: StaticOptions): MiddlewareHandler;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A static file server middleware. Serves files from a directory (default
|
|
3
|
+
* `public/`) before your routes run — if a file matches the request path it's
|
|
4
|
+
* sent with caching headers; otherwise the request continues to your routes.
|
|
5
|
+
*
|
|
6
|
+
* `node:fs` is imported dynamically, so the core still loads on the edge; add
|
|
7
|
+
* this middleware only in Node apps (on Workers, serve assets via the platform).
|
|
8
|
+
*
|
|
9
|
+
* this.use(serveStatic()); // ./public
|
|
10
|
+
* this.use(serveStatic({ root: "./assets", maxAge: 86400, immutable: true }));
|
|
11
|
+
*/
|
|
12
|
+
import { getMimeType } from "hono/utils/mime";
|
|
13
|
+
export function serveStatic(options = {}) {
|
|
14
|
+
const root = (options.root ?? "./public").replace(/\/+$/, "");
|
|
15
|
+
const index = options.index ?? "index.html";
|
|
16
|
+
const dotFiles = options.dotFiles ?? "ignore";
|
|
17
|
+
return async (c, next) => {
|
|
18
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD")
|
|
19
|
+
return next();
|
|
20
|
+
const urlPath = decodeURIComponent(new URL(c.req.url).pathname);
|
|
21
|
+
if (urlPath.includes(".."))
|
|
22
|
+
return next(); // path traversal guard
|
|
23
|
+
const hasDotSegment = urlPath.split("/").some((seg) => seg.startsWith("."));
|
|
24
|
+
if (hasDotSegment) {
|
|
25
|
+
if (dotFiles === "deny")
|
|
26
|
+
return c.text("Forbidden", 403);
|
|
27
|
+
if (dotFiles === "ignore")
|
|
28
|
+
return next();
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const { stat, readFile } = await import("node:fs/promises");
|
|
32
|
+
let filePath = root + urlPath;
|
|
33
|
+
let stats = await stat(filePath).catch(() => null);
|
|
34
|
+
if (stats?.isDirectory()) {
|
|
35
|
+
filePath = `${filePath.replace(/\/+$/, "")}/${index}`;
|
|
36
|
+
stats = await stat(filePath).catch(() => null);
|
|
37
|
+
}
|
|
38
|
+
if (!stats || !stats.isFile())
|
|
39
|
+
return next();
|
|
40
|
+
const etag = `W/"${stats.size}-${Math.round(stats.mtimeMs)}"`;
|
|
41
|
+
c.header("Content-Type", getMimeType(filePath) ?? "application/octet-stream");
|
|
42
|
+
c.header("Last-Modified", stats.mtime.toUTCString());
|
|
43
|
+
c.header("ETag", etag);
|
|
44
|
+
if (options.maxAge != null) {
|
|
45
|
+
c.header("Cache-Control", `public, max-age=${options.maxAge}${options.immutable ? ", immutable" : ""}`);
|
|
46
|
+
}
|
|
47
|
+
const extra = options.headers?.(filePath);
|
|
48
|
+
if (extra)
|
|
49
|
+
for (const [k, v] of Object.entries(extra))
|
|
50
|
+
c.header(k, v);
|
|
51
|
+
if (c.req.header("If-None-Match") === etag)
|
|
52
|
+
return c.body(null, 304);
|
|
53
|
+
if (c.req.method === "HEAD")
|
|
54
|
+
return c.body(null, 200);
|
|
55
|
+
return c.body(await readFile(filePath), 200);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
package/package.json
CHANGED