weifuwu 0.25.2 → 0.27.1
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 +291 -2489
- package/ai/provider.ts +129 -0
- package/ai/stream.ts +63 -0
- package/{dist/cli.d.ts → cli.js} +1 -1
- package/cli.ts +55 -257
- package/core/cookie.ts +114 -0
- package/core/env.ts +142 -0
- package/core/logger.ts +72 -0
- package/core/router.ts +795 -0
- package/core/serve.ts +294 -0
- package/core/sse.ts +85 -0
- package/core/trace.ts +146 -0
- package/graphql.ts +267 -0
- package/hub.ts +133 -0
- package/index.ts +71 -0
- package/mailer.ts +81 -0
- package/middleware/compress.ts +103 -0
- package/middleware/cors.ts +81 -0
- package/middleware/csrf.ts +112 -0
- package/middleware/flash.ts +144 -0
- package/middleware/health.ts +44 -0
- package/middleware/helmet.ts +98 -0
- package/middleware/i18n.ts +175 -0
- package/middleware/rate-limit.ts +167 -0
- package/middleware/request-id.ts +60 -0
- package/middleware/static.ts +149 -0
- package/middleware/theme.ts +84 -0
- package/middleware/upload.ts +168 -0
- package/middleware/validate.ts +186 -0
- package/package.json +15 -36
- package/postgres/client.ts +132 -0
- package/postgres/index.ts +4 -0
- package/postgres/module.ts +37 -0
- package/postgres/schema/columns.ts +186 -0
- package/postgres/schema/index.ts +36 -0
- package/postgres/schema/sql.ts +39 -0
- package/postgres/schema/table.ts +548 -0
- package/postgres/schema/where.ts +99 -0
- package/postgres/types.ts +48 -0
- package/queue/cron.ts +90 -0
- package/queue/index.ts +654 -0
- package/queue/types.ts +60 -0
- package/redis/client.ts +24 -0
- package/{dist/redis/index.d.ts → redis/index.ts} +2 -2
- package/redis/types.ts +28 -0
- package/types.ts +78 -0
- package/cli/template/app.ts +0 -22
- package/cli/template/index.ts +0 -10
- package/cli/template/locales/en.json +0 -13
- package/cli/template/locales/zh-CN.json +0 -13
- package/cli/template/locales/zh-TW.json +0 -13
- package/cli/template/locales/zh.json +0 -13
- package/cli/template/ui/app/globals.css +0 -2
- package/cli/template/ui/app/layout.tsx +0 -15
- package/cli/template/ui/app/page.tsx +0 -124
- package/cli/template/ui/components/Greeting.tsx +0 -3
- package/dist/agent/client.d.ts +0 -2
- package/dist/agent/index.d.ts +0 -2
- package/dist/agent/rest.d.ts +0 -14
- package/dist/agent/run.d.ts +0 -19
- package/dist/agent/types.d.ts +0 -55
- package/dist/ai/provider.d.ts +0 -45
- package/dist/ai/utils.d.ts +0 -5
- package/dist/ai/workflow.d.ts +0 -17
- package/dist/ai-sdk.d.ts +0 -2
- package/dist/ai.d.ts +0 -13
- package/dist/analytics.d.ts +0 -45
- package/dist/auth.d.ts +0 -22
- package/dist/cache.d.ts +0 -74
- package/dist/cli.js +0 -302
- package/dist/client-locale.d.ts +0 -25
- package/dist/client-pref.d.ts +0 -3
- package/dist/client-router.d.ts +0 -300
- package/dist/client-state.d.ts +0 -22
- package/dist/client-theme.d.ts +0 -36
- package/dist/compile.d.ts +0 -15
- package/dist/compress.d.ts +0 -20
- package/dist/cookie.d.ts +0 -36
- package/dist/cors.d.ts +0 -25
- package/dist/cron-utils.d.ts +0 -73
- package/dist/csrf.d.ts +0 -47
- package/dist/deploy/config.d.ts +0 -2
- package/dist/deploy/gateway.d.ts +0 -2
- package/dist/deploy/index.d.ts +0 -4
- package/dist/deploy/manager.d.ts +0 -16
- package/dist/deploy/process.d.ts +0 -14
- package/dist/deploy/types.d.ts +0 -53
- package/dist/env.d.ts +0 -69
- package/dist/error-boundary.d.ts +0 -2
- package/dist/flash.d.ts +0 -90
- package/dist/fts.d.ts +0 -36
- package/dist/graphql.d.ts +0 -16
- package/dist/head.d.ts +0 -6
- package/dist/health.d.ts +0 -24
- package/dist/helmet.d.ts +0 -33
- package/dist/html-shell.d.ts +0 -1
- package/dist/hub.d.ts +0 -37
- package/dist/i18n.d.ts +0 -39
- package/dist/iii/client.d.ts +0 -2
- package/dist/iii/index.d.ts +0 -4
- package/dist/iii/register-worker.d.ts +0 -9
- package/dist/iii/rest.d.ts +0 -3
- package/dist/iii/stream.d.ts +0 -82
- package/dist/iii/types.d.ts +0 -121
- package/dist/iii/worker.d.ts +0 -2
- package/dist/iii/ws.d.ts +0 -22
- package/dist/index.d.ts +0 -101
- package/dist/index.js +0 -12752
- package/dist/kb/index.d.ts +0 -3
- package/dist/kb/types.d.ts +0 -72
- package/dist/layout.d.ts +0 -2
- package/dist/live.d.ts +0 -7
- package/dist/logdb/client.d.ts +0 -2
- package/dist/logdb/index.d.ts +0 -2
- package/dist/logdb/rest.d.ts +0 -5
- package/dist/logdb/types.d.ts +0 -27
- package/dist/logger.d.ts +0 -16
- package/dist/mailer.d.ts +0 -51
- package/dist/mcp.d.ts +0 -34
- package/dist/messager/agent.d.ts +0 -11
- package/dist/messager/client.d.ts +0 -2
- package/dist/messager/index.d.ts +0 -2
- package/dist/messager/rest.d.ts +0 -15
- package/dist/messager/types.d.ts +0 -57
- package/dist/messager/ws.d.ts +0 -14
- package/dist/module-server.d.ts +0 -9
- package/dist/not-found.d.ts +0 -2
- package/dist/notifier/client.d.ts +0 -2
- package/dist/notifier/index.d.ts +0 -2
- package/dist/notifier/types.d.ts +0 -105
- package/dist/opencode/client.d.ts +0 -2
- package/dist/opencode/index.d.ts +0 -2
- package/dist/opencode/permissions.d.ts +0 -5
- package/dist/opencode/prompt.d.ts +0 -8
- package/dist/opencode/rest.d.ts +0 -16
- package/dist/opencode/run.d.ts +0 -13
- package/dist/opencode/session.d.ts +0 -26
- package/dist/opencode/skills.d.ts +0 -4
- package/dist/opencode/tools/bash.d.ts +0 -6
- package/dist/opencode/tools/edit.d.ts +0 -19
- package/dist/opencode/tools/glob.d.ts +0 -9
- package/dist/opencode/tools/grep.d.ts +0 -17
- package/dist/opencode/tools/index.d.ts +0 -12
- package/dist/opencode/tools/question.d.ts +0 -5
- package/dist/opencode/tools/read.d.ts +0 -16
- package/dist/opencode/tools/skill.d.ts +0 -18
- package/dist/opencode/tools/web.d.ts +0 -18
- package/dist/opencode/tools/write.d.ts +0 -13
- package/dist/opencode/types.d.ts +0 -90
- package/dist/opencode/ws.d.ts +0 -21
- package/dist/permissions.d.ts +0 -51
- package/dist/postgres/client.d.ts +0 -4
- package/dist/postgres/index.d.ts +0 -4
- package/dist/postgres/module.d.ts +0 -17
- package/dist/postgres/schema/columns.d.ts +0 -99
- package/dist/postgres/schema/index.d.ts +0 -6
- package/dist/postgres/schema/sql.d.ts +0 -22
- package/dist/postgres/schema/table.d.ts +0 -141
- package/dist/postgres/schema/where.d.ts +0 -29
- package/dist/postgres/types.d.ts +0 -50
- package/dist/queue/index.d.ts +0 -2
- package/dist/queue/types.d.ts +0 -62
- package/dist/rate-limit.d.ts +0 -45
- package/dist/react.d.ts +0 -14
- package/dist/react.js +0 -751
- package/dist/redis/client.d.ts +0 -2
- package/dist/redis/types.d.ts +0 -18
- package/dist/request-id.d.ts +0 -40
- package/dist/router.d.ts +0 -73
- package/dist/s3.d.ts +0 -68
- package/dist/seo.d.ts +0 -104
- package/dist/serve.d.ts +0 -38
- package/dist/server-registry.d.ts +0 -10
- package/dist/session.d.ts +0 -117
- package/dist/sse.d.ts +0 -47
- package/dist/ssr-entries.d.ts +0 -4
- package/dist/ssr.d.ts +0 -11
- package/dist/static.d.ts +0 -23
- package/dist/stream.d.ts +0 -24
- package/dist/tailwind.d.ts +0 -15
- package/dist/tenant/client.d.ts +0 -2
- package/dist/tenant/graphql.d.ts +0 -3
- package/dist/tenant/index.d.ts +0 -2
- package/dist/tenant/rest.d.ts +0 -3
- package/dist/tenant/schema.d.ts +0 -5
- package/dist/tenant/types.d.ts +0 -48
- package/dist/tenant/utils.d.ts +0 -9
- package/dist/test-utils.d.ts +0 -194
- package/dist/theme.d.ts +0 -31
- package/dist/trace.d.ts +0 -95
- package/dist/tsx-context.d.ts +0 -32
- package/dist/types.d.ts +0 -47
- package/dist/upload.d.ts +0 -55
- package/dist/use-action.d.ts +0 -42
- package/dist/use-agent-stream.d.ts +0 -49
- package/dist/use-flash-message.d.ts +0 -17
- package/dist/use-websocket.d.ts +0 -42
- package/dist/user/client.d.ts +0 -30
- package/dist/user/index.d.ts +0 -2
- package/dist/user/oauth-login.d.ts +0 -21
- package/dist/user/oauth2.d.ts +0 -31
- package/dist/user/types.d.ts +0 -178
- package/dist/validate.d.ts +0 -32
- package/dist/vendor.d.ts +0 -7
- package/dist/webhook.d.ts +0 -79
- package/opencode/ui/app/globals.css +0 -1
- package/opencode/ui/app/layout.tsx +0 -13
- package/opencode/ui/app/page.tsx +0 -523
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { Redis, Context, Handler, Middleware, Closeable } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
/** Options for {@link rateLimit}. */
|
|
4
|
+
export interface RateLimitOptions {
|
|
5
|
+
/** Maximum requests within the window (default: 100). */
|
|
6
|
+
max?: number
|
|
7
|
+
/** Window duration in ms (default: 60000 = 1 minute). */
|
|
8
|
+
window?: number
|
|
9
|
+
/** Custom key function. Default: IP from `x-forwarded-for` or `x-real-ip` or `cf-connecting-ip`. */
|
|
10
|
+
key?: (req: Request, ctx: Context) => string
|
|
11
|
+
/** Custom 429 response body." */
|
|
12
|
+
message?: string
|
|
13
|
+
/** Store backend. `'memory'` (default) or `'redis'`. */
|
|
14
|
+
store?: 'memory' | 'redis'
|
|
15
|
+
/** Redis client (required when `store: 'redis'`). */
|
|
16
|
+
redis?: Redis
|
|
17
|
+
/** Redis key prefix (default: `'ratelimit:'`). */
|
|
18
|
+
prefix?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultKey(_req: Request, _ctx: Context): string {
|
|
22
|
+
const forwarded = _req.headers.get('x-forwarded-for')
|
|
23
|
+
if (forwarded) return forwarded.split(',')[0]!.trim()
|
|
24
|
+
const realIp = _req.headers.get('x-real-ip')
|
|
25
|
+
if (realIp) return realIp
|
|
26
|
+
const cfIp = _req.headers.get('cf-connecting-ip')
|
|
27
|
+
if (cfIp) return cfIp
|
|
28
|
+
return 'global'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Rate limit module — middleware + stats. */
|
|
32
|
+
export interface RateLimitModule extends Middleware<Context, Context>, Closeable {
|
|
33
|
+
stats(): { store: string; entries?: number; maxEntries: number }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rate limiting middleware (in-memory or Redis-backed).
|
|
38
|
+
*
|
|
39
|
+
* Limits requests per key (default: client IP) within a rolling window.
|
|
40
|
+
* Returns 429 when the limit is exceeded, with `Retry-After` header.
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { rateLimit } from 'weifuwu'
|
|
44
|
+
*
|
|
45
|
+
* // In-memory (single process)
|
|
46
|
+
* app.use(rateLimit({ max: 60, window: 60_000 }))
|
|
47
|
+
*
|
|
48
|
+
* // Redis-backed (multi-process)
|
|
49
|
+
* import { Redis } from 'ioredis'
|
|
50
|
+
* app.use(rateLimit({ store: 'redis', redis: new Redis(), max: 100 }))
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function rateLimit(options?: RateLimitOptions): RateLimitModule {
|
|
54
|
+
const max = options?.max ?? 100
|
|
55
|
+
const window = options?.window ?? 60_000
|
|
56
|
+
const getKey = options?.key ?? defaultKey
|
|
57
|
+
const message = options?.message ?? 'Too Many Requests'
|
|
58
|
+
const storeType = options?.store ?? 'memory'
|
|
59
|
+
|
|
60
|
+
if (storeType === 'redis' && !options?.redis) {
|
|
61
|
+
throw new Error('rateLimit: redis client required when store: "redis"')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const redis = options?.redis ?? null
|
|
65
|
+
const keyPrefix = options?.prefix ?? 'ratelimit:'
|
|
66
|
+
|
|
67
|
+
// Memory store: in-memory counter map
|
|
68
|
+
const MAX_ENTRIES = 10000
|
|
69
|
+
const hits = new Map<string, { count: number; reset: number }>()
|
|
70
|
+
|
|
71
|
+
const interval =
|
|
72
|
+
storeType === 'memory'
|
|
73
|
+
? setInterval(
|
|
74
|
+
() => {
|
|
75
|
+
const now = Date.now()
|
|
76
|
+
for (const [key, entry] of hits) {
|
|
77
|
+
if (entry.reset < now) hits.delete(key)
|
|
78
|
+
}
|
|
79
|
+
if (hits.size > MAX_ENTRIES) {
|
|
80
|
+
const toDelete = hits.size - MAX_ENTRIES
|
|
81
|
+
let deleted = 0
|
|
82
|
+
for (const key of hits.keys()) {
|
|
83
|
+
if (deleted >= toDelete) break
|
|
84
|
+
hits.delete(key)
|
|
85
|
+
deleted++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
Math.min(window, 30000),
|
|
90
|
+
)
|
|
91
|
+
: null
|
|
92
|
+
|
|
93
|
+
if (interval?.unref) interval.unref()
|
|
94
|
+
|
|
95
|
+
// Shared rate check logic — dispatches to memory or redis
|
|
96
|
+
async function checkAndIncrement(key: string): Promise<{ count: number; reset: number }> {
|
|
97
|
+
const now = Date.now()
|
|
98
|
+
|
|
99
|
+
if (storeType === 'redis' && redis) {
|
|
100
|
+
const redisKey = `${keyPrefix}${key}`
|
|
101
|
+
const count = await redis.incr(redisKey)
|
|
102
|
+
if (count === 1) {
|
|
103
|
+
await redis.pexpire(redisKey, window)
|
|
104
|
+
}
|
|
105
|
+
const pttl = await redis.pttl(redisKey)
|
|
106
|
+
const reset = pttl > 0 ? now + pttl : now + window
|
|
107
|
+
return { count, reset }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Memory store
|
|
111
|
+
const entry = hits.get(key)
|
|
112
|
+
if (!entry || entry.reset < now) {
|
|
113
|
+
hits.set(key, { count: 1, reset: now + window })
|
|
114
|
+
return { count: 1, reset: now + window }
|
|
115
|
+
}
|
|
116
|
+
entry.count++
|
|
117
|
+
return { count: entry.count, reset: entry.reset }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mw = async (req: Request, ctx: Context, next: Handler) => {
|
|
121
|
+
const key = getKey(req, ctx)
|
|
122
|
+
const now = Date.now()
|
|
123
|
+
|
|
124
|
+
const { count, reset } = await checkAndIncrement(key)
|
|
125
|
+
|
|
126
|
+
if (count > max) {
|
|
127
|
+
return new Response(message, {
|
|
128
|
+
status: 429,
|
|
129
|
+
headers: {
|
|
130
|
+
'Retry-After': String(Math.ceil((reset - now) / 1000)),
|
|
131
|
+
'X-RateLimit-Limit': String(max),
|
|
132
|
+
'X-RateLimit-Remaining': '0',
|
|
133
|
+
'X-RateLimit-Reset': String(Math.ceil(reset / 1000)),
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const remaining = max - count
|
|
139
|
+
const res = await next(req, ctx)
|
|
140
|
+
return addRateLimitHeaders(res, max, remaining, reset)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
mw.__meta = { injects: [], depends: [] }
|
|
144
|
+
mw.close = async () => {
|
|
145
|
+
if (interval) clearInterval(interval)
|
|
146
|
+
hits.clear()
|
|
147
|
+
}
|
|
148
|
+
mw.stats = () => ({
|
|
149
|
+
store: storeType,
|
|
150
|
+
entries: storeType === 'memory' ? hits.size : undefined,
|
|
151
|
+
maxEntries: MAX_ENTRIES,
|
|
152
|
+
})
|
|
153
|
+
return mw
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function addRateLimitHeaders(
|
|
157
|
+
res: Response,
|
|
158
|
+
limit: number,
|
|
159
|
+
remaining: number,
|
|
160
|
+
reset: number,
|
|
161
|
+
): Response {
|
|
162
|
+
const headers = new Headers(res.headers)
|
|
163
|
+
headers.set('X-RateLimit-Limit', String(limit))
|
|
164
|
+
headers.set('X-RateLimit-Remaining', String(remaining))
|
|
165
|
+
headers.set('X-RateLimit-Reset', String(Math.ceil(reset / 1000)))
|
|
166
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
167
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import type { Context, Middleware } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
// Augment Context with requestId property
|
|
5
|
+
declare module '../types.ts' {
|
|
6
|
+
interface Context {
|
|
7
|
+
requestId: string
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Options for {@link requestId}. */
|
|
12
|
+
/** Request ID module — a {@link Middleware} that injects `ctx.requestId`. */
|
|
13
|
+
export type RequestIdModule = Middleware<Context, Context & { requestId: string }>
|
|
14
|
+
|
|
15
|
+
export interface RequestIdOptions {
|
|
16
|
+
/** Header name for request ID (default: `'X-Request-ID'`). */
|
|
17
|
+
header?: string
|
|
18
|
+
/** Custom ID generator (default: `crypto.randomUUID`). */
|
|
19
|
+
generator?: () => string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Request ID middleware.
|
|
24
|
+
*
|
|
25
|
+
* @deprecated Use `trace()` from 'weifuwu' instead — it injects `ctx.trace.requestId`
|
|
26
|
+
* along with `traceId` and `elapsed()` in a single middleware.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* // Old:
|
|
30
|
+
* app.use(requestId())
|
|
31
|
+
* ctx.requestId
|
|
32
|
+
*
|
|
33
|
+
* // New:
|
|
34
|
+
* app.use(trace())
|
|
35
|
+
* ctx.trace.requestId
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Reads an incoming `X-Request-ID` header (or custom header name) from the
|
|
39
|
+
* request. If absent, generates a new UUID. Sets the response header and
|
|
40
|
+
* injects `ctx.requestId`.
|
|
41
|
+
*/
|
|
42
|
+
export function requestId(
|
|
43
|
+
options?: RequestIdOptions,
|
|
44
|
+
): Middleware<Context, Context & { requestId: string }> {
|
|
45
|
+
const header = options?.header ?? 'X-Request-ID'
|
|
46
|
+
const gen = options?.generator ?? (() => crypto.randomUUID())
|
|
47
|
+
|
|
48
|
+
const mw: Middleware<Context, Context & { requestId: string }> = async (req, ctx, next) => {
|
|
49
|
+
const existing = req.headers.get(header)
|
|
50
|
+
const id = existing ?? gen()
|
|
51
|
+
;(ctx as unknown as { requestId: string }).requestId = id
|
|
52
|
+
const res = await next(req, ctx as Context & { requestId: string })
|
|
53
|
+
if (res.headers.has(header)) return res
|
|
54
|
+
const h = new Headers(res.headers)
|
|
55
|
+
h.set(header, id)
|
|
56
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h })
|
|
57
|
+
}
|
|
58
|
+
mw.__meta = { injects: ['requestId'], depends: [] }
|
|
59
|
+
return mw
|
|
60
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { open, realpath, type FileHandle } from 'node:fs/promises'
|
|
2
|
+
import { extname, resolve, normalize, sep } from 'node:path'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import type { Handler } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
/** Options for {@link serveStatic}. */
|
|
7
|
+
export interface ServeStaticOptions {
|
|
8
|
+
/** Directory index filename (default: `'index.html'`). */
|
|
9
|
+
index?: string
|
|
10
|
+
/** `Cache-Control max-age` in seconds. */
|
|
11
|
+
maxAge?: number
|
|
12
|
+
/** Add `immutable` to `Cache-Control` (requires `maxAge`). */
|
|
13
|
+
immutable?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Static file serving handler.
|
|
18
|
+
*
|
|
19
|
+
* Serves files from a root directory. Supports ETag/304, directory index,
|
|
20
|
+
* Content-Type detection by extension, and directory traversal protection.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { serveStatic, Router } from 'weifuwu'
|
|
24
|
+
* const app = new Router()
|
|
25
|
+
* app.get('/static/*', serveStatic('./public'))
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function serveStatic(root: string, options?: ServeStaticOptions): Handler {
|
|
29
|
+
const rootDir = resolve(root)
|
|
30
|
+
|
|
31
|
+
const opts = options ?? {}
|
|
32
|
+
|
|
33
|
+
return async (req, ctx) => {
|
|
34
|
+
const relativePath = ctx.params['*'] ?? new URL(req.url).pathname.slice(1)
|
|
35
|
+
const decoded = decodeURIComponent(relativePath)
|
|
36
|
+
|
|
37
|
+
if (decoded.includes('..') || decoded.includes('\0')) {
|
|
38
|
+
return new Response('Forbidden', { status: 403 })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let filePath = normalize(resolve(rootDir, decoded))
|
|
42
|
+
if (!filePath.startsWith(rootDir + sep) && filePath !== rootDir) {
|
|
43
|
+
return new Response('Forbidden', { status: 403 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let fileHandle: FileHandle | undefined
|
|
47
|
+
try {
|
|
48
|
+
fileHandle = await open(filePath, 'r')
|
|
49
|
+
let stat = await fileHandle.stat()
|
|
50
|
+
|
|
51
|
+
// Resolve symlinks and verify within root
|
|
52
|
+
const realPath = await realpath(filePath)
|
|
53
|
+
if (!realPath.startsWith(rootDir + sep) && realPath !== rootDir) {
|
|
54
|
+
await fileHandle.close()
|
|
55
|
+
return new Response('Forbidden', { status: 403 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (stat.isDirectory()) {
|
|
59
|
+
await fileHandle.close()
|
|
60
|
+
const indexFile = opts.index ?? 'index.html'
|
|
61
|
+
filePath = resolve(filePath, indexFile)
|
|
62
|
+
if (!filePath.startsWith(rootDir + sep)) {
|
|
63
|
+
return new Response('Forbidden', { status: 403 })
|
|
64
|
+
}
|
|
65
|
+
fileHandle = await open(filePath, 'r')
|
|
66
|
+
stat = await fileHandle.stat()
|
|
67
|
+
if (!stat.isFile()) {
|
|
68
|
+
await fileHandle.close()
|
|
69
|
+
return new Response('Not Found', { status: 404 })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
|
|
74
|
+
|
|
75
|
+
const etag = `"${stat.ino}-${stat.size}-${stat.mtimeMs}"`
|
|
76
|
+
const ifNoneMatch = req.headers.get('if-none-match')
|
|
77
|
+
if (ifNoneMatch === etag) {
|
|
78
|
+
await fileHandle.close()
|
|
79
|
+
return new Response(null, { status: 304 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ifModifiedSince = req.headers.get('if-modified-since')
|
|
83
|
+
if (ifModifiedSince && stat.mtimeMs <= new Date(ifModifiedSince).getTime()) {
|
|
84
|
+
await fileHandle.close()
|
|
85
|
+
return new Response(null, { status: 304 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const headers: Record<string, string> = {
|
|
89
|
+
'Content-Type': mimeType,
|
|
90
|
+
'Content-Length': String(stat.size),
|
|
91
|
+
ETag: etag,
|
|
92
|
+
'Last-Modified': stat.mtime.toUTCString(),
|
|
93
|
+
'Cache-Control': opts.immutable
|
|
94
|
+
? `public, max-age=${opts.maxAge ?? 31536000}, immutable`
|
|
95
|
+
: `public, max-age=${opts.maxAge ?? 0}`,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const readStream = fileHandle!.createReadStream()
|
|
99
|
+
const cleanup = () => fileHandle!.close().catch(() => {})
|
|
100
|
+
readStream.on('close', cleanup)
|
|
101
|
+
readStream.on('error', cleanup)
|
|
102
|
+
const webStream = Readable.toWeb(readStream)
|
|
103
|
+
return new Response(webStream as unknown as ReadableStream, { headers })
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (fileHandle) await fileHandle.close().catch(() => {})
|
|
106
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
107
|
+
return new Response('Not Found', { status: 404 })
|
|
108
|
+
}
|
|
109
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MIME_TYPES: Record<string, string> = {
|
|
115
|
+
'.html': 'text/html; charset=utf-8',
|
|
116
|
+
'.htm': 'text/html; charset=utf-8',
|
|
117
|
+
'.css': 'text/css; charset=utf-8',
|
|
118
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
119
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
120
|
+
'.json': 'application/json',
|
|
121
|
+
'.png': 'image/png',
|
|
122
|
+
'.jpg': 'image/jpeg',
|
|
123
|
+
'.jpeg': 'image/jpeg',
|
|
124
|
+
'.gif': 'image/gif',
|
|
125
|
+
'.svg': 'image/svg+xml',
|
|
126
|
+
'.ico': 'image/x-icon',
|
|
127
|
+
'.webp': 'image/webp',
|
|
128
|
+
'.avif': 'image/avif',
|
|
129
|
+
'.woff': 'font/woff',
|
|
130
|
+
'.woff2': 'font/woff2',
|
|
131
|
+
'.ttf': 'font/ttf',
|
|
132
|
+
'.otf': 'font/otf',
|
|
133
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
134
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
135
|
+
'.xml': 'application/xml',
|
|
136
|
+
'.pdf': 'application/pdf',
|
|
137
|
+
'.zip': 'application/zip',
|
|
138
|
+
'.wasm': 'application/wasm',
|
|
139
|
+
'.map': 'application/json',
|
|
140
|
+
'.ts': 'application/x-typescript',
|
|
141
|
+
'.tsx': 'application/x-typescript',
|
|
142
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
143
|
+
'.yaml': 'application/x-yaml',
|
|
144
|
+
'.yml': 'application/x-yaml',
|
|
145
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
146
|
+
'.mp4': 'video/mp4',
|
|
147
|
+
'.mp3': 'audio/mpeg',
|
|
148
|
+
'.wav': 'audio/wav',
|
|
149
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Context, Middleware } from '../types.ts'
|
|
2
|
+
import { getCookies } from '../core/cookie.ts'
|
|
3
|
+
import { Router } from '../core/router.ts'
|
|
4
|
+
|
|
5
|
+
// Augment Context with theme property
|
|
6
|
+
declare module '../types.ts' {
|
|
7
|
+
interface Context {
|
|
8
|
+
theme: ThemeInjected
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ThemeInjected {
|
|
13
|
+
value: string
|
|
14
|
+
set: (value: string, loc?: string) => Response
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ThemeOptions {
|
|
18
|
+
/** Default theme value (default: 'system'). */
|
|
19
|
+
default?: string
|
|
20
|
+
/** Cookie name (default: 'theme'). Set to empty string to disable cookie. */
|
|
21
|
+
cookie?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeSetTheme(cookie: string, location: string) {
|
|
25
|
+
return (value: string, loc?: string) => {
|
|
26
|
+
const finalLoc = loc ?? location
|
|
27
|
+
const c = `${cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`
|
|
28
|
+
return new Response(null, { status: 302, headers: { Location: finalLoc, 'Set-Cookie': c } })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Theme module. Returns a Router with an attached `.middleware()` method.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const t = theme()
|
|
37
|
+
* app.use(t.middleware()) // → ctx.theme = { value, set }
|
|
38
|
+
* app.use('/', t) // → GET /__theme/dark (switch route)
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export interface ThemeModule extends Router {
|
|
42
|
+
/** Middleware that injects `ctx.theme = { value, set }`. */
|
|
43
|
+
middleware: () => Middleware<Context, Context & ThemeInjected>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function theme(options?: ThemeOptions): ThemeModule {
|
|
47
|
+
const opts = { default: 'system', cookie: 'theme', ...options }
|
|
48
|
+
|
|
49
|
+
const mw: Middleware<Context, Context & ThemeInjected> = async (req, ctx, next) => {
|
|
50
|
+
let themeValue = opts.default
|
|
51
|
+
if (opts.cookie) {
|
|
52
|
+
const fromCookie = getCookies(req)[opts.cookie]
|
|
53
|
+
if (fromCookie) themeValue = fromCookie
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
;(ctx as Context & ThemeInjected).theme = {
|
|
57
|
+
value: themeValue,
|
|
58
|
+
set: makeSetTheme(opts.cookie, req.headers.get('referer') || '/'),
|
|
59
|
+
}
|
|
60
|
+
return next(req, ctx as Context & ThemeInjected)
|
|
61
|
+
}
|
|
62
|
+
mw.__meta = { injects: ['theme'], depends: [] }
|
|
63
|
+
|
|
64
|
+
class ThemeRouter extends Router {
|
|
65
|
+
middleware() {
|
|
66
|
+
return mw
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const router = new ThemeRouter()
|
|
71
|
+
router.get('/__theme/:value', (req) => {
|
|
72
|
+
const url = new URL(req.url)
|
|
73
|
+
const value = url.pathname.split('/__theme/')[1] ?? ''
|
|
74
|
+
const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`
|
|
75
|
+
const accept = req.headers.get('accept') ?? ''
|
|
76
|
+
if (accept.includes('application/json')) {
|
|
77
|
+
return Response.json({ ok: true, theme: value }, { headers: { 'Set-Cookie': cookie } })
|
|
78
|
+
}
|
|
79
|
+
const referer = req.headers.get('referer') || '/'
|
|
80
|
+
return new Response(null, { status: 302, headers: { Location: referer, 'Set-Cookie': cookie } })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return router
|
|
84
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { join, extname } from 'node:path'
|
|
5
|
+
import type { Context, Middleware } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
// Augment Context with parsed property (shared with validate)
|
|
8
|
+
declare module '../types.ts' {
|
|
9
|
+
interface Context {
|
|
10
|
+
parsed: Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Upload middleware — a {@link Middleware} that injects `ctx.parsed` with file fields. */
|
|
15
|
+
export type UploadModule = Middleware<Context, Context & { parsed: Record<string, unknown> }>
|
|
16
|
+
|
|
17
|
+
/** A parsed file from a multipart upload. */
|
|
18
|
+
export interface UploadedFile {
|
|
19
|
+
/** Original filename from the client. */
|
|
20
|
+
name: string
|
|
21
|
+
/** MIME type from the `Content-Type` part header. */
|
|
22
|
+
type: string
|
|
23
|
+
/** File size in bytes. */
|
|
24
|
+
size: number
|
|
25
|
+
/** Path where the file was saved (when `dir` option is set). */
|
|
26
|
+
path?: string
|
|
27
|
+
/** File content as Buffer (when `dir` option is not set). */
|
|
28
|
+
buffer?: Buffer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Options for {@link upload}. */
|
|
32
|
+
export interface UploadOptions {
|
|
33
|
+
/** Directory to save uploaded files. If not set, files stay in memory via `.buffer`. */
|
|
34
|
+
dir?: string
|
|
35
|
+
/** Maximum file size in bytes. Default: 10 MB. Set `0` to allow unlimited. */
|
|
36
|
+
maxFileSize?: number
|
|
37
|
+
/** Allowed MIME types (e.g. `['image/jpeg', 'image/png']`). Empty array allows all. */
|
|
38
|
+
allowedTypes?: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const extensionMimeMap: Record<string, string> = {
|
|
42
|
+
'.jpg': 'image/jpeg',
|
|
43
|
+
'.jpeg': 'image/jpeg',
|
|
44
|
+
'.png': 'image/png',
|
|
45
|
+
'.gif': 'image/gif',
|
|
46
|
+
'.webp': 'image/webp',
|
|
47
|
+
'.svg': 'image/svg+xml',
|
|
48
|
+
'.pdf': 'application/pdf',
|
|
49
|
+
'.doc': 'application/msword',
|
|
50
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
51
|
+
'.xls': 'application/vnd.ms-excel',
|
|
52
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
53
|
+
'.zip': 'application/zip',
|
|
54
|
+
'.gz': 'application/gzip',
|
|
55
|
+
'.mp4': 'video/mp4',
|
|
56
|
+
'.mp3': 'audio/mpeg',
|
|
57
|
+
'.wav': 'audio/wav',
|
|
58
|
+
'.json': 'application/json',
|
|
59
|
+
'.csv': 'text/csv',
|
|
60
|
+
'.txt': 'text/plain',
|
|
61
|
+
'.html': 'text/html',
|
|
62
|
+
'.css': 'text/css',
|
|
63
|
+
'.js': 'text/javascript',
|
|
64
|
+
'.ts': 'application/x-typescript',
|
|
65
|
+
'.tsx': 'application/x-typescript',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectMimeFromExtension(filename: string): string | undefined {
|
|
69
|
+
return extensionMimeMap[extname(filename).toLowerCase()]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Multipart file upload middleware.
|
|
74
|
+
*
|
|
75
|
+
* Parses `multipart/form-data` requests, extracting files and fields.
|
|
76
|
+
* Files can be saved to disk (`dir` option) or kept in memory as Buffers.
|
|
77
|
+
* Parsed fields are available in `ctx.parsed`.
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* import { upload } from 'weifuwu'
|
|
81
|
+
*
|
|
82
|
+
* // Save to disk
|
|
83
|
+
* app.use(upload({ dir: './uploads', maxFileSize: 5_000_000 }))
|
|
84
|
+
*
|
|
85
|
+
* // In-memory
|
|
86
|
+
* app.post('/upload', async (req, ctx) => {
|
|
87
|
+
* const file = ctx.parsed?.file as UploadedFile
|
|
88
|
+
* console.log(file.name, file.type, file.buffer!.length)
|
|
89
|
+
* })
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function upload(
|
|
93
|
+
options?: UploadOptions,
|
|
94
|
+
): Middleware<Context, Context & { parsed: Record<string, unknown> }> {
|
|
95
|
+
const saveDir = options?.dir
|
|
96
|
+
|
|
97
|
+
const mw: Middleware<Context, Context & { parsed: Record<string, unknown> }> = async (
|
|
98
|
+
req,
|
|
99
|
+
ctx,
|
|
100
|
+
next,
|
|
101
|
+
) => {
|
|
102
|
+
const ct = req.headers.get('content-type') ?? ''
|
|
103
|
+
if (!ct.includes('multipart/form-data')) return next(req, ctx)
|
|
104
|
+
try {
|
|
105
|
+
if (saveDir) await mkdir(saveDir, { recursive: true })
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.error('upload: failed to create directory', saveDir, e)
|
|
108
|
+
return Response.json({ error: 'Server configuration error' }, { status: 500 })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let formData: FormData
|
|
112
|
+
try {
|
|
113
|
+
formData = await req.formData()
|
|
114
|
+
} catch {
|
|
115
|
+
return Response.json({ error: 'Invalid multipart data' }, { status: 400 })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const files: Record<string, UploadedFile | UploadedFile[]> = {}
|
|
119
|
+
const fields: Record<string, string> = {}
|
|
120
|
+
|
|
121
|
+
for (const [key, value] of formData) {
|
|
122
|
+
if (value instanceof File) {
|
|
123
|
+
// Validate: check client-declared type AND extension-based type
|
|
124
|
+
if (options?.allowedTypes) {
|
|
125
|
+
const clientOk = options.allowedTypes.includes(value.type)
|
|
126
|
+
const extType = detectMimeFromExtension(value.name)
|
|
127
|
+
const extOk = extType ? options.allowedTypes.includes(extType) : false
|
|
128
|
+
if (!clientOk && !extOk) {
|
|
129
|
+
return Response.json({ error: `File type not allowed: ${value.type}` }, { status: 415 })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (options?.maxFileSize && value.size > options.maxFileSize) {
|
|
133
|
+
return Response.json({ error: `File too large: ${value.name}` }, { status: 413 })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const buf = Buffer.from(await value.arrayBuffer())
|
|
137
|
+
|
|
138
|
+
const uf: UploadedFile = {
|
|
139
|
+
name: value.name,
|
|
140
|
+
type: value.type,
|
|
141
|
+
size: buf.byteLength,
|
|
142
|
+
buffer: saveDir ? undefined : buf,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (saveDir) {
|
|
146
|
+
const safeName = value.name.replace(/[/\\\0]/g, '_').replace(/\.\./g, '_')
|
|
147
|
+
const filePath = join(saveDir, `${randomUUID()}-${safeName}`)
|
|
148
|
+
await writeFile(filePath, buf)
|
|
149
|
+
uf.path = filePath
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (files[key]) {
|
|
153
|
+
const existing = files[key]
|
|
154
|
+
files[key] = Array.isArray(existing) ? [...existing, uf] : [existing, uf]
|
|
155
|
+
} else {
|
|
156
|
+
files[key] = uf
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
fields[key] = value
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
ctx.parsed = { ...ctx.parsed, files, fields }
|
|
164
|
+
return next(req, ctx)
|
|
165
|
+
}
|
|
166
|
+
mw.__meta = { injects: ['parsed'], depends: [] }
|
|
167
|
+
return mw
|
|
168
|
+
}
|