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.
Files changed (208) hide show
  1. package/README.md +291 -2489
  2. package/ai/provider.ts +129 -0
  3. package/ai/stream.ts +63 -0
  4. package/{dist/cli.d.ts → cli.js} +1 -1
  5. package/cli.ts +55 -257
  6. package/core/cookie.ts +114 -0
  7. package/core/env.ts +142 -0
  8. package/core/logger.ts +72 -0
  9. package/core/router.ts +795 -0
  10. package/core/serve.ts +294 -0
  11. package/core/sse.ts +85 -0
  12. package/core/trace.ts +146 -0
  13. package/graphql.ts +267 -0
  14. package/hub.ts +133 -0
  15. package/index.ts +71 -0
  16. package/mailer.ts +81 -0
  17. package/middleware/compress.ts +103 -0
  18. package/middleware/cors.ts +81 -0
  19. package/middleware/csrf.ts +112 -0
  20. package/middleware/flash.ts +144 -0
  21. package/middleware/health.ts +44 -0
  22. package/middleware/helmet.ts +98 -0
  23. package/middleware/i18n.ts +175 -0
  24. package/middleware/rate-limit.ts +167 -0
  25. package/middleware/request-id.ts +60 -0
  26. package/middleware/static.ts +149 -0
  27. package/middleware/theme.ts +84 -0
  28. package/middleware/upload.ts +168 -0
  29. package/middleware/validate.ts +186 -0
  30. package/package.json +15 -36
  31. package/postgres/client.ts +132 -0
  32. package/postgres/index.ts +4 -0
  33. package/postgres/module.ts +37 -0
  34. package/postgres/schema/columns.ts +186 -0
  35. package/postgres/schema/index.ts +36 -0
  36. package/postgres/schema/sql.ts +39 -0
  37. package/postgres/schema/table.ts +548 -0
  38. package/postgres/schema/where.ts +99 -0
  39. package/postgres/types.ts +48 -0
  40. package/queue/cron.ts +90 -0
  41. package/queue/index.ts +654 -0
  42. package/queue/types.ts +60 -0
  43. package/redis/client.ts +24 -0
  44. package/{dist/redis/index.d.ts → redis/index.ts} +2 -2
  45. package/redis/types.ts +28 -0
  46. package/types.ts +78 -0
  47. package/cli/template/app.ts +0 -22
  48. package/cli/template/index.ts +0 -10
  49. package/cli/template/locales/en.json +0 -13
  50. package/cli/template/locales/zh-CN.json +0 -13
  51. package/cli/template/locales/zh-TW.json +0 -13
  52. package/cli/template/locales/zh.json +0 -13
  53. package/cli/template/ui/app/globals.css +0 -2
  54. package/cli/template/ui/app/layout.tsx +0 -15
  55. package/cli/template/ui/app/page.tsx +0 -124
  56. package/cli/template/ui/components/Greeting.tsx +0 -3
  57. package/dist/agent/client.d.ts +0 -2
  58. package/dist/agent/index.d.ts +0 -2
  59. package/dist/agent/rest.d.ts +0 -14
  60. package/dist/agent/run.d.ts +0 -19
  61. package/dist/agent/types.d.ts +0 -55
  62. package/dist/ai/provider.d.ts +0 -45
  63. package/dist/ai/utils.d.ts +0 -5
  64. package/dist/ai/workflow.d.ts +0 -17
  65. package/dist/ai-sdk.d.ts +0 -2
  66. package/dist/ai.d.ts +0 -13
  67. package/dist/analytics.d.ts +0 -45
  68. package/dist/auth.d.ts +0 -22
  69. package/dist/cache.d.ts +0 -74
  70. package/dist/cli.js +0 -302
  71. package/dist/client-locale.d.ts +0 -25
  72. package/dist/client-pref.d.ts +0 -3
  73. package/dist/client-router.d.ts +0 -300
  74. package/dist/client-state.d.ts +0 -22
  75. package/dist/client-theme.d.ts +0 -36
  76. package/dist/compile.d.ts +0 -15
  77. package/dist/compress.d.ts +0 -20
  78. package/dist/cookie.d.ts +0 -36
  79. package/dist/cors.d.ts +0 -25
  80. package/dist/cron-utils.d.ts +0 -73
  81. package/dist/csrf.d.ts +0 -47
  82. package/dist/deploy/config.d.ts +0 -2
  83. package/dist/deploy/gateway.d.ts +0 -2
  84. package/dist/deploy/index.d.ts +0 -4
  85. package/dist/deploy/manager.d.ts +0 -16
  86. package/dist/deploy/process.d.ts +0 -14
  87. package/dist/deploy/types.d.ts +0 -53
  88. package/dist/env.d.ts +0 -69
  89. package/dist/error-boundary.d.ts +0 -2
  90. package/dist/flash.d.ts +0 -90
  91. package/dist/fts.d.ts +0 -36
  92. package/dist/graphql.d.ts +0 -16
  93. package/dist/head.d.ts +0 -6
  94. package/dist/health.d.ts +0 -24
  95. package/dist/helmet.d.ts +0 -33
  96. package/dist/html-shell.d.ts +0 -1
  97. package/dist/hub.d.ts +0 -37
  98. package/dist/i18n.d.ts +0 -39
  99. package/dist/iii/client.d.ts +0 -2
  100. package/dist/iii/index.d.ts +0 -4
  101. package/dist/iii/register-worker.d.ts +0 -9
  102. package/dist/iii/rest.d.ts +0 -3
  103. package/dist/iii/stream.d.ts +0 -82
  104. package/dist/iii/types.d.ts +0 -121
  105. package/dist/iii/worker.d.ts +0 -2
  106. package/dist/iii/ws.d.ts +0 -22
  107. package/dist/index.d.ts +0 -101
  108. package/dist/index.js +0 -12752
  109. package/dist/kb/index.d.ts +0 -3
  110. package/dist/kb/types.d.ts +0 -72
  111. package/dist/layout.d.ts +0 -2
  112. package/dist/live.d.ts +0 -7
  113. package/dist/logdb/client.d.ts +0 -2
  114. package/dist/logdb/index.d.ts +0 -2
  115. package/dist/logdb/rest.d.ts +0 -5
  116. package/dist/logdb/types.d.ts +0 -27
  117. package/dist/logger.d.ts +0 -16
  118. package/dist/mailer.d.ts +0 -51
  119. package/dist/mcp.d.ts +0 -34
  120. package/dist/messager/agent.d.ts +0 -11
  121. package/dist/messager/client.d.ts +0 -2
  122. package/dist/messager/index.d.ts +0 -2
  123. package/dist/messager/rest.d.ts +0 -15
  124. package/dist/messager/types.d.ts +0 -57
  125. package/dist/messager/ws.d.ts +0 -14
  126. package/dist/module-server.d.ts +0 -9
  127. package/dist/not-found.d.ts +0 -2
  128. package/dist/notifier/client.d.ts +0 -2
  129. package/dist/notifier/index.d.ts +0 -2
  130. package/dist/notifier/types.d.ts +0 -105
  131. package/dist/opencode/client.d.ts +0 -2
  132. package/dist/opencode/index.d.ts +0 -2
  133. package/dist/opencode/permissions.d.ts +0 -5
  134. package/dist/opencode/prompt.d.ts +0 -8
  135. package/dist/opencode/rest.d.ts +0 -16
  136. package/dist/opencode/run.d.ts +0 -13
  137. package/dist/opencode/session.d.ts +0 -26
  138. package/dist/opencode/skills.d.ts +0 -4
  139. package/dist/opencode/tools/bash.d.ts +0 -6
  140. package/dist/opencode/tools/edit.d.ts +0 -19
  141. package/dist/opencode/tools/glob.d.ts +0 -9
  142. package/dist/opencode/tools/grep.d.ts +0 -17
  143. package/dist/opencode/tools/index.d.ts +0 -12
  144. package/dist/opencode/tools/question.d.ts +0 -5
  145. package/dist/opencode/tools/read.d.ts +0 -16
  146. package/dist/opencode/tools/skill.d.ts +0 -18
  147. package/dist/opencode/tools/web.d.ts +0 -18
  148. package/dist/opencode/tools/write.d.ts +0 -13
  149. package/dist/opencode/types.d.ts +0 -90
  150. package/dist/opencode/ws.d.ts +0 -21
  151. package/dist/permissions.d.ts +0 -51
  152. package/dist/postgres/client.d.ts +0 -4
  153. package/dist/postgres/index.d.ts +0 -4
  154. package/dist/postgres/module.d.ts +0 -17
  155. package/dist/postgres/schema/columns.d.ts +0 -99
  156. package/dist/postgres/schema/index.d.ts +0 -6
  157. package/dist/postgres/schema/sql.d.ts +0 -22
  158. package/dist/postgres/schema/table.d.ts +0 -141
  159. package/dist/postgres/schema/where.d.ts +0 -29
  160. package/dist/postgres/types.d.ts +0 -50
  161. package/dist/queue/index.d.ts +0 -2
  162. package/dist/queue/types.d.ts +0 -62
  163. package/dist/rate-limit.d.ts +0 -45
  164. package/dist/react.d.ts +0 -14
  165. package/dist/react.js +0 -751
  166. package/dist/redis/client.d.ts +0 -2
  167. package/dist/redis/types.d.ts +0 -18
  168. package/dist/request-id.d.ts +0 -40
  169. package/dist/router.d.ts +0 -73
  170. package/dist/s3.d.ts +0 -68
  171. package/dist/seo.d.ts +0 -104
  172. package/dist/serve.d.ts +0 -38
  173. package/dist/server-registry.d.ts +0 -10
  174. package/dist/session.d.ts +0 -117
  175. package/dist/sse.d.ts +0 -47
  176. package/dist/ssr-entries.d.ts +0 -4
  177. package/dist/ssr.d.ts +0 -11
  178. package/dist/static.d.ts +0 -23
  179. package/dist/stream.d.ts +0 -24
  180. package/dist/tailwind.d.ts +0 -15
  181. package/dist/tenant/client.d.ts +0 -2
  182. package/dist/tenant/graphql.d.ts +0 -3
  183. package/dist/tenant/index.d.ts +0 -2
  184. package/dist/tenant/rest.d.ts +0 -3
  185. package/dist/tenant/schema.d.ts +0 -5
  186. package/dist/tenant/types.d.ts +0 -48
  187. package/dist/tenant/utils.d.ts +0 -9
  188. package/dist/test-utils.d.ts +0 -194
  189. package/dist/theme.d.ts +0 -31
  190. package/dist/trace.d.ts +0 -95
  191. package/dist/tsx-context.d.ts +0 -32
  192. package/dist/types.d.ts +0 -47
  193. package/dist/upload.d.ts +0 -55
  194. package/dist/use-action.d.ts +0 -42
  195. package/dist/use-agent-stream.d.ts +0 -49
  196. package/dist/use-flash-message.d.ts +0 -17
  197. package/dist/use-websocket.d.ts +0 -42
  198. package/dist/user/client.d.ts +0 -30
  199. package/dist/user/index.d.ts +0 -2
  200. package/dist/user/oauth-login.d.ts +0 -21
  201. package/dist/user/oauth2.d.ts +0 -31
  202. package/dist/user/types.d.ts +0 -178
  203. package/dist/validate.d.ts +0 -32
  204. package/dist/vendor.d.ts +0 -7
  205. package/dist/webhook.d.ts +0 -79
  206. package/opencode/ui/app/globals.css +0 -1
  207. package/opencode/ui/app/layout.tsx +0 -13
  208. 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
+ }