weifuwu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,47 @@
1
+ This is the weifuwu HTTP framework — pure Node.js, no build step.
2
+
3
+ ## Commands
4
+
5
+ - `node --test` — run all tests
6
+ - `npm install` — install dependencies
7
+ - `npx tsc --noEmit` — type-check without emitting
8
+
9
+ ## TypeScript rules
10
+
11
+ - All imports must use explicit `.ts` extensions (e.g. `import { x } from './foo.ts'`)
12
+ - Node.js v26+ supports TypeScript natively with `--experimental-strip-types`
13
+ - No `tsc` compiler needed for runtime (native TS via Node.js)
14
+
15
+ ## Code conventions
16
+
17
+ - Read the full file before editing — context matters
18
+ - Follow existing patterns: `Handler = (req, ctx) => Response | Promise<Response>`
19
+ - All middleware returns a `Middleware` — `(req, ctx, next) => Response | Promise<Response>`
20
+ - Import types from `./types.ts`, source from individual files
21
+ - New modules get their own file, exported from `index.ts`
22
+ - Every module needs tests in `test/`
23
+ - All `ctx` mutations (like `ctx.parsed` or `ctx.user`) should be additive, never overwrite
24
+
25
+ ## Dependencies
26
+
27
+ - `ws` for WebSocket server
28
+ - `graphql` + `@graphql-tools/schema` for GraphQL
29
+ - `ai` (Vercel AI SDK) for AI streaming
30
+ - `zod` for request validation
31
+ - Node.js built-in `WebSocket` for WebSocket clients
32
+ - Node.js built-in `zlib` for response compression
33
+
34
+ ## Testing
35
+
36
+ ```ts#test/example.test.ts
37
+ import { describe, it } from 'node:test'
38
+ import assert from 'node:assert/strict'
39
+
40
+ describe('example', () => {
41
+ it('works', () => {
42
+ assert.equal(1 + 1, 2)
43
+ })
44
+ })
45
+ ```
46
+
47
+ Tests live in `test/` and follow the pattern: create a `Router`, call `r.handler()(request, ctx)`, assert on the response. For end-to-end tests, use `serve()`.
package/README.md ADDED
@@ -0,0 +1,316 @@
1
+ # weifuwu
2
+
3
+ **Web-standard HTTP framework for Node.js.** `(req, ctx) => Response` — no framework-specific objects, just the Web API your browser already speaks.
4
+
5
+ ## Features
6
+
7
+ - **Web Standard** — `Request` / `Response` / `ReadableStream`, zero abstractions
8
+ - **Trie router** — static > param > wildcard, sub-router mounting, path params
9
+ - **Middleware** — global, path-scoped, route-level — onion model, short-circuit
10
+ - **Built-in middleware** — `auth()`, `cors()`, `logger()`, `rateLimit()`, `compress()`
11
+ - **WebSocket** — `router.ws()` with upgrade middleware (auth before connect)
12
+ - **GraphQL** — `router.graphql()` with GraphiQL IDE
13
+ - **AI streaming** — `router.ai()` via Vercel AI SDK
14
+ - **Static files** — `serveStatic()` with ETag, 304, MIME, directory index
15
+ - **Request validation** — `validate()` with Zod (body / query / params / headers)
16
+ - **File upload** — `upload()` multipart parser with disk save, size & type limits
17
+ - **Cookie** — `getCookies()`, `setCookie()`, `deleteCookie()` — immutable
18
+ - **Error handling** — global `onError()`
19
+ - **Zero build** — native TypeScript in Node.js v24+
20
+ - **Zero deps** (core) — only `node:http` and `node:stream`
21
+
22
+ ## Quick start
23
+
24
+ ```ts
25
+ import { serve } from 'weifuwu'
26
+
27
+ serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
28
+ ```
29
+
30
+ ## Router
31
+
32
+ ```ts
33
+ import { serve, Router } from 'weifuwu'
34
+
35
+ const app = new Router()
36
+ .use((req, ctx, next) => {
37
+ console.log(`${req.method} ${new URL(req.url).pathname}`)
38
+ return next(req, ctx)
39
+ })
40
+ .get('/hello/:name', (req, ctx) =>
41
+ Response.json({ message: `Hello, ${ctx.params.name}!` }),
42
+ )
43
+ .post('/data', async (req, ctx) => {
44
+ const body = await req.json()
45
+ return Response.json(body, { status: 201 })
46
+ })
47
+
48
+ serve(app.handler(), { port: 3000 })
49
+ ```
50
+
51
+ ## Built-in middleware
52
+
53
+ ### Auth
54
+
55
+ ```ts
56
+ import { auth } from 'weifuwu'
57
+
58
+ // Static bearer token
59
+ app.use(auth({ token: 'sk-123' }))
60
+
61
+ // Custom verify (JWT, DB, etc.) — return object to set ctx.user
62
+ app.use(auth({
63
+ verify: async (token) => {
64
+ const user = await db.findUserByToken(token)
65
+ return user ? { sub: user.id, role: user.role } : null
66
+ },
67
+ }))
68
+
69
+ // Proxy validation to external auth service
70
+ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
71
+
72
+ // Custom header
73
+ app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
74
+ ```
75
+
76
+ ### CORS
77
+
78
+ ```ts
79
+ import { cors } from 'weifuwu'
80
+
81
+ app.use(cors()) // allow all
82
+ app.use(cors({ origin: ['https://example.com'] })) // whitelist
83
+ app.use(cors({ origin: (o) => o.endsWith('.trusted.com') ? o : false }))
84
+ app.use(cors({ credentials: true, maxAge: 3600 }))
85
+ ```
86
+
87
+ ### Logger
88
+
89
+ ```ts
90
+ import { logger } from 'weifuwu'
91
+
92
+ app.use(logger()) // GET /hello 200 5ms
93
+ app.use(logger({ format: 'combined' })) // with query params
94
+ ```
95
+
96
+ ### Rate limit
97
+
98
+ ```ts
99
+ import { rateLimit } from 'weifuwu'
100
+
101
+ app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
102
+ app.get('/api', rateLimit({ max: 10 }), handler) // per-route
103
+
104
+ // Custom key (by API key, user ID, etc.)
105
+ app.use(rateLimit({
106
+ max: 1000,
107
+ key: (req) => req.headers.get('x-api-key') ?? 'anonymous',
108
+ }))
109
+ ```
110
+
111
+ ### Compression
112
+
113
+ ```ts
114
+ import { compress } from 'weifuwu'
115
+
116
+ app.use(compress()) // brotli > gzip > deflate
117
+ app.use(compress({ threshold: 2048 })) // only compress > 2KB
118
+ ```
119
+
120
+ ## Static files
121
+
122
+ ```ts
123
+ import { serveStatic } from 'weifuwu'
124
+
125
+ router.get('/static/*', serveStatic('./public'))
126
+ ```
127
+
128
+ Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
129
+
130
+ ## Validation
131
+
132
+ ```ts
133
+ import { z } from 'zod'
134
+ import { validate } from 'weifuwu'
135
+
136
+ const CreateUser = z.object({
137
+ name: z.string().min(1),
138
+ email: z.string().email(),
139
+ })
140
+
141
+ router.post('/users',
142
+ validate({ body: CreateUser }),
143
+ (req, ctx) => {
144
+ // ctx.parsed.body — typed & validated
145
+ },
146
+ )
147
+
148
+ // Validate multiple dimensions at once
149
+ router.post('/data',
150
+ validate({
151
+ body: z.object({ value: z.number() }),
152
+ query: z.object({ token: z.string() }),
153
+ params: z.object({ id: z.string().length(24) }),
154
+ }),
155
+ handler,
156
+ )
157
+ ```
158
+
159
+ ## File upload
160
+
161
+ ```ts
162
+ import { upload } from 'weifuwu'
163
+
164
+ router.post('/upload',
165
+ upload({ dir: './uploads', maxFileSize: 10_485_760 }),
166
+ (req, ctx) => {
167
+ // ctx.parsed.files.avatar → { name, type, size, path }
168
+ // ctx.parsed.fields.title → 'hello'
169
+ },
170
+ )
171
+ ```
172
+
173
+ ## Cookie
174
+
175
+ ```ts
176
+ import { getCookies, setCookie, deleteCookie } from 'weifuwu'
177
+
178
+ // Read
179
+ const cookies = getCookies(req) // { session: 'abc' }
180
+
181
+ // Set (immutable — returns new Response)
182
+ let res = new Response('ok')
183
+ res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge: 3600 })
184
+
185
+ // Delete
186
+ res = deleteCookie(res, 'session')
187
+ ```
188
+
189
+ ## WebSocket
190
+
191
+ ```ts
192
+ const app = new Router()
193
+ .ws('/chat/:room', {
194
+ open(ws, ctx) {
195
+ ws.send(`Connected to room: ${ctx.params.room}`)
196
+ },
197
+ message(ws, ctx, data) {
198
+ ws.send(`echo: ${data}`)
199
+ },
200
+ close(ws, ctx) {
201
+ console.log('disconnected')
202
+ },
203
+ })
204
+
205
+ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
206
+ ```
207
+
208
+ Middleware runs **before** WebSocket upgrade — you can reject connections with HTTP status codes:
209
+
210
+ ```ts
211
+ app.ws('/secure',
212
+ (req, _ctx, next) => {
213
+ const auth = req.headers.get('Authorization')
214
+ if (!auth) return Response.json({ error: 'Unauthorized' }, { status: 401 })
215
+ return next(req, _ctx)
216
+ },
217
+ { open(ws) { ws.send('authorized') } },
218
+ )
219
+ ```
220
+
221
+ ## GraphQL
222
+
223
+ ```ts
224
+ const app = new Router()
225
+ .graphql('/graphql', {
226
+ schema: `
227
+ type Query { hello: String }
228
+ type Mutation { setMessage(msg: String!): String }
229
+ `,
230
+ resolvers: {
231
+ Query: { hello: () => 'world' },
232
+ Mutation: { setMessage: (_, { msg }) => msg },
233
+ },
234
+ graphiql: true,
235
+ })
236
+
237
+ serve(app.handler(), { port: 3000 })
238
+ ```
239
+
240
+ ## AI streaming
241
+
242
+ ```ts
243
+ import { openai } from '@ai-sdk/openai'
244
+
245
+ const app = new Router()
246
+ .ai('/chat', async (req) => {
247
+ const { messages } = await req.json()
248
+ return {
249
+ model: openai('gpt-4o'),
250
+ messages,
251
+ }
252
+ })
253
+
254
+ serve(app.handler(), { port: 3000 })
255
+ ```
256
+
257
+ ## Error handling
258
+
259
+ ```ts
260
+ const app = new Router()
261
+ .onError((err, req, ctx) =>
262
+ Response.json({ error: err.message }, { status: 500 }),
263
+ )
264
+ .get('/crash', () => { throw new Error('boom') })
265
+ ```
266
+
267
+ ## API
268
+
269
+ ### `serve(handler, options?)`
270
+
271
+ | Option | Default | Description |
272
+ |--------|---------|-------------|
273
+ | `port` | `0` | Listen port (`0` = random) |
274
+ | `hostname` | `'0.0.0.0'` | Bind address |
275
+ | `signal` | — | `AbortSignal` for graceful shutdown |
276
+ | `websocket` | — | Upgrade handler from `router.websocketHandler()` |
277
+
278
+ Returns `{ stop, port, hostname, ready }`.
279
+
280
+ ### `Router`
281
+
282
+ | Method | Description |
283
+ |--------|-------------|
284
+ | `get/post/put/delete/patch/head/options/all(path, ...mws, handler)` | Route registration |
285
+ | `use(mw)` / `use(path, mw)` / `use(path, subRouter)` | Middleware / sub-router |
286
+ | `ws(path, ...mws, handler)` | WebSocket route |
287
+ | `graphql(path, ...mws, options)` | GraphQL endpoint |
288
+ | `ai(path, ...mws, handler)` | AI streaming |
289
+ | `onError(handler)` | Global error handler |
290
+ | `handler()` | Returns `(req, ctx) => Response` for `serve()` |
291
+ | `websocketHandler()` | Returns upgrade handler for `serve({ websocket })` |
292
+
293
+ ### Built-in middleware
294
+
295
+ | Function | Description |
296
+ |----------|-------------|
297
+ | `auth(options)` | Bearer token / custom header / verify / proxy |
298
+ | `cors(options?)` | CORS with preflight, origin whitelist, credentials |
299
+ | `logger(options?)` | Request logging with duration |
300
+ | `rateLimit(options?)` | In-memory rate limiting with headers |
301
+ | `compress(options?)` | Brotli / Gzip / Deflate compression |
302
+
303
+ ### Utilities
304
+
305
+ | Function | Description |
306
+ |----------|-------------|
307
+ | `serveStatic(root, options?)` | Static file serving handler |
308
+ | `validate(schemas)` | Zod validation middleware |
309
+ | `upload(options?)` | Multipart file upload middleware |
310
+ | `getCookies(req)` | Parse Cookie header → object |
311
+ | `setCookie(res, name, value, options?)` | Set cookie (returns new Response) |
312
+ | `deleteCookie(res, name)` | Delete cookie (returns new Response) |
313
+
314
+ ## License
315
+
316
+ MIT
package/compress.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
2
+ import type { Middleware } from './types.ts'
3
+
4
+ export interface CompressOptions {
5
+ level?: number
6
+ threshold?: number
7
+ }
8
+
9
+ export function compress(options?: CompressOptions): Middleware {
10
+ const level = options?.level ?? 6
11
+ const threshold = options?.threshold ?? 1024
12
+
13
+ return async (req, ctx, next) => {
14
+ const accept = req.headers.get('accept-encoding') ?? ''
15
+
16
+ const useBrotli = accept.includes('br')
17
+ const useGzip = !useBrotli && accept.includes('gzip')
18
+ const useDeflate = !useBrotli && !useGzip && accept.includes('deflate')
19
+
20
+ if (!useBrotli && !useGzip && !useDeflate) {
21
+ return next(req, ctx)
22
+ }
23
+
24
+ const res = await next(req, ctx)
25
+
26
+ if (res.status === 304 || res.status === 204 || res.status < 200 || res.status >= 300) {
27
+ return res
28
+ }
29
+
30
+ const ce = res.headers.get('content-encoding')
31
+ if (ce) return res
32
+
33
+ const ct = res.headers.get('content-type') ?? ''
34
+ if (!ct || ct.startsWith('audio/') || ct.startsWith('video/') || ct.startsWith('image/') || ct === 'application/zip') {
35
+ return res
36
+ }
37
+
38
+ const body = await res.bytes()
39
+ if (body.byteLength < threshold) return res
40
+
41
+ let compressed: Buffer
42
+ let encoding: string
43
+
44
+ if (useBrotli) {
45
+ compressed = brotliCompressSync(body, {
46
+ params: { [constants.BROTLI_PARAM_QUALITY]: Math.min(level, 11) },
47
+ })
48
+ encoding = 'br'
49
+ } else if (useGzip) {
50
+ compressed = gzipSync(body, { level: Math.min(level, 9) })
51
+ encoding = 'gzip'
52
+ } else {
53
+ compressed = gzipSync(body, { level: Math.min(level, 9) })
54
+ encoding = 'deflate'
55
+ }
56
+
57
+ const headers = new Headers(res.headers)
58
+ headers.set('Content-Encoding', encoding)
59
+ headers.set('Content-Length', String(compressed.byteLength))
60
+ headers.delete('Content-Range')
61
+ headers.set('Vary', 'Accept-Encoding')
62
+
63
+ return new Response(compressed, {
64
+ status: res.status,
65
+ statusText: res.statusText,
66
+ headers,
67
+ })
68
+ }
69
+ }
package/cookie.ts ADDED
@@ -0,0 +1,58 @@
1
+ export interface CookieOptions {
2
+ domain?: string
3
+ path?: string
4
+ maxAge?: number
5
+ expires?: Date
6
+ httpOnly?: boolean
7
+ secure?: boolean
8
+ sameSite?: 'strict' | 'lax' | 'none'
9
+ }
10
+
11
+ export function getCookies(req: Request): Record<string, string> {
12
+ const header = req.headers.get('cookie')
13
+ if (!header) return {}
14
+
15
+ const cookies: Record<string, string> = {}
16
+ for (const pair of header.split(';')) {
17
+ const idx = pair.indexOf('=')
18
+ if (idx === -1) continue
19
+ const name = pair.slice(0, idx).trim()
20
+ const value = pair.slice(idx + 1).trim()
21
+ if (name) {
22
+ cookies[name] = decodeURIComponent(value)
23
+ }
24
+ }
25
+ return cookies
26
+ }
27
+
28
+ function serializeCookie(name: string, value: string, options?: CookieOptions): string {
29
+ const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]
30
+ if (options?.maxAge != null) parts.push(`Max-Age=${options.maxAge}`)
31
+ if (options?.expires) parts.push(`Expires=${options.expires.toUTCString()}`)
32
+ if (options?.domain) parts.push(`Domain=${options.domain}`)
33
+ if (options?.path) parts.push(`Path=${options.path}`)
34
+ if (options?.httpOnly) parts.push('HttpOnly')
35
+ if (options?.secure) parts.push('Secure')
36
+ if (options?.sameSite) parts.push(`SameSite=${options.sameSite}`)
37
+ return parts.join('; ')
38
+ }
39
+
40
+ export function setCookie(res: Response, name: string, value: string, options?: CookieOptions): Response {
41
+ const headers = new Headers(res.headers)
42
+ headers.append('Set-Cookie', serializeCookie(name, value, options))
43
+ return new Response(res.body, {
44
+ status: res.status,
45
+ statusText: res.statusText,
46
+ headers,
47
+ })
48
+ }
49
+
50
+ export function deleteCookie(res: Response, name: string, options?: Omit<CookieOptions, 'maxAge'>): Response {
51
+ const headers = new Headers(res.headers)
52
+ headers.append('Set-Cookie', serializeCookie(name, '', { ...options, maxAge: 0 }))
53
+ return new Response(res.body, {
54
+ status: res.status,
55
+ statusText: res.statusText,
56
+ headers,
57
+ })
58
+ }
package/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export type { Context, Handler, Middleware, ErrorHandler } from './types.ts'
2
+ export { serve } from './serve.ts'
3
+ export type { ServeOptions, Server } from './serve.ts'
4
+ export { Router } from './router.ts'
5
+ export type { WebSocketHandler, GraphQLOptions, AIHandler } from './router.ts'
6
+ export { auth, cors, logger } from './middleware.ts'
7
+ export type { AuthOptions, CORSOptions, LoggerOptions } from './middleware.ts'
8
+ export { serveStatic } from './static.ts'
9
+ export type { ServeStaticOptions } from './static.ts'
10
+ export { validate } from './validate.ts'
11
+ export type { ValidationSchemas } from './validate.ts'
12
+ export { getCookies, setCookie, deleteCookie } from './cookie.ts'
13
+ export type { CookieOptions } from './cookie.ts'
14
+ export { upload } from './upload.ts'
15
+ export type { UploadOptions, UploadedFile } from './upload.ts'
16
+ export { rateLimit } from './rate-limit.ts'
17
+ export type { RateLimitOptions } from './rate-limit.ts'
18
+ export { compress } from './compress.ts'
19
+ export type { CompressOptions } from './compress.ts'
package/middleware.ts ADDED
@@ -0,0 +1,178 @@
1
+ import type { Middleware } from './types.ts'
2
+
3
+ // ── Logger ────────────────────────────────────────────────────────────────────
4
+
5
+ export interface LoggerOptions {
6
+ format?: 'short' | 'combined'
7
+ }
8
+
9
+ export function logger(options?: LoggerOptions): Middleware {
10
+ return async (req, ctx, next) => {
11
+ const start = Date.now()
12
+ const url = new URL(req.url)
13
+ const res = await next(req, ctx)
14
+ const ms = Date.now() - start
15
+
16
+ if (options?.format === 'combined') {
17
+ console.log(`${req.method} ${url.pathname}${url.search} ${res.status} ${ms}ms`)
18
+ } else {
19
+ console.log(`${req.method} ${url.pathname} ${res.status} ${ms}ms`)
20
+ }
21
+
22
+ return res
23
+ }
24
+ }
25
+
26
+ // ── CORS ──────────────────────────────────────────────────────────────────────
27
+
28
+ export interface CORSOptions {
29
+ origin?: string | string[] | ((origin: string) => string | boolean | undefined)
30
+ methods?: string[]
31
+ allowedHeaders?: string[]
32
+ exposedHeaders?: string[]
33
+ credentials?: boolean
34
+ maxAge?: number
35
+ }
36
+
37
+ export function cors(options?: CORSOptions): Middleware {
38
+ const opts: Required<Pick<CORSOptions, 'origin' | 'methods' | 'allowedHeaders'>> & CORSOptions = {
39
+ origin: '*',
40
+ methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
41
+ allowedHeaders: ['Content-Type', 'Authorization'],
42
+ ...options,
43
+ }
44
+
45
+ function resolveOrigin(requestOrigin: string): string {
46
+ if (typeof opts.origin === 'string') return opts.origin === '*' ? '*' : opts.origin
47
+ if (Array.isArray(opts.origin)) {
48
+ return opts.origin.includes(requestOrigin) ? requestOrigin : ''
49
+ }
50
+ const result = opts.origin(requestOrigin)
51
+ if (typeof result === 'boolean') return result ? requestOrigin : ''
52
+ if (typeof result === 'string') return result
53
+ return ''
54
+ }
55
+
56
+ function setCORSHeaders(res: Response, acao: string): Response {
57
+ if (!acao) return res
58
+ const headers = new Headers(res.headers)
59
+ headers.set('Access-Control-Allow-Origin', acao)
60
+ if (opts.credentials) headers.set('Access-Control-Allow-Credentials', 'true')
61
+ if (opts.exposedHeaders?.length) headers.set('Access-Control-Expose-Headers', opts.exposedHeaders.join(', '))
62
+ headers.set('Vary', 'Origin')
63
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
64
+ }
65
+
66
+ return (req, ctx, next) => {
67
+ const requestOrigin = req.headers.get('origin') ?? ''
68
+ const acao = resolveOrigin(requestOrigin)
69
+
70
+ if (req.method === 'OPTIONS' && acao) {
71
+ const headers = new Headers()
72
+ headers.set('Access-Control-Allow-Origin', acao)
73
+ headers.set('Access-Control-Allow-Methods', opts.methods.join(', '))
74
+ headers.set('Access-Control-Allow-Headers', opts.allowedHeaders.join(', '))
75
+ if (opts.credentials) headers.set('Access-Control-Allow-Credentials', 'true')
76
+ if (opts.maxAge != null) headers.set('Access-Control-Max-Age', String(opts.maxAge))
77
+ headers.set('Vary', 'Origin')
78
+ return new Response(null, { status: 204, headers })
79
+ }
80
+
81
+ if (!acao) return next(req, ctx)
82
+
83
+ return Promise.resolve(next(req, ctx)).then((res) => setCORSHeaders(res, acao))
84
+ }
85
+ }
86
+
87
+ // ── Auth ───────────────────────────────────────────────────────────────────────
88
+
89
+ export interface AuthOptions {
90
+ token?: string
91
+ verify?: (token: string, req: Request) => unknown | Promise<unknown>
92
+ proxy?: string | URL
93
+ header?: string
94
+ }
95
+
96
+ export function auth(options: AuthOptions): Middleware {
97
+ return async (req, ctx, next) => {
98
+ const headerName = options.header ?? 'Authorization'
99
+ const header = req.headers.get(headerName)
100
+
101
+ if (!header) {
102
+ return new Response('Unauthorized', {
103
+ status: 401,
104
+ headers: headerName.toLowerCase() === 'authorization'
105
+ ? { 'WWW-Authenticate': 'Bearer' }
106
+ : undefined,
107
+ })
108
+ }
109
+
110
+ let token = header
111
+ if (headerName.toLowerCase() === 'authorization') {
112
+ const parts = header.split(' ')
113
+ if (parts[0]?.toLowerCase() === 'bearer') {
114
+ token = parts.slice(1).join(' ')
115
+ }
116
+ }
117
+
118
+ // ── Proxy mode ──────────────────────────────────────────────────────────
119
+ if (options.proxy) {
120
+ const proxyUrl = typeof options.proxy === 'string'
121
+ ? new URL(options.proxy)
122
+ : options.proxy
123
+
124
+ const proxyHeaders: Record<string, string> = {}
125
+
126
+ if (headerName.toLowerCase() === 'authorization') {
127
+ proxyHeaders['Authorization'] = header
128
+ } else {
129
+ proxyUrl.searchParams.set('access_token', token)
130
+ }
131
+
132
+ for (const name of ['x-forwarded-for', 'x-real-ip', 'user-agent', 'content-type']) {
133
+ const v = req.headers.get(name)
134
+ if (v) proxyHeaders[name] = v
135
+ }
136
+
137
+ const proxyRes = await fetch(proxyUrl.href, { headers: proxyHeaders })
138
+
139
+ if (proxyRes.status >= 400) {
140
+ return new Response(await proxyRes.text() || 'Forbidden', { status: proxyRes.status })
141
+ }
142
+
143
+ let userData: unknown = undefined
144
+ if (proxyRes.status === 200) {
145
+ const ct = proxyRes.headers.get('content-type')
146
+ if (ct?.includes('application/json')) {
147
+ try { userData = await proxyRes.json() } catch {}
148
+ }
149
+ }
150
+
151
+ ctx.user = userData
152
+ return next(req, ctx)
153
+ }
154
+
155
+ // ── Static token mode ───────────────────────────────────────────────────
156
+ if (options.token) {
157
+ if (token !== options.token) {
158
+ return new Response('Forbidden', { status: 403 })
159
+ }
160
+ return next(req, ctx)
161
+ }
162
+
163
+ // ── Verify mode ─────────────────────────────────────────────────────────
164
+ if (options.verify) {
165
+ const result = await options.verify(token, req)
166
+ if (!result) {
167
+ return new Response('Forbidden', { status: 403 })
168
+ }
169
+ if (typeof result === 'object' && result !== null) {
170
+ ctx.user = result
171
+ }
172
+ return next(req, ctx)
173
+ }
174
+
175
+ // ── Trust any token (no validation configured) ─────────────────────────
176
+ return next(req, ctx)
177
+ }
178
+ }