weifuwu 0.27.2 → 0.27.3

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 (89) hide show
  1. package/dist/ai/provider.d.ts +45 -0
  2. package/dist/ai/stream.d.ts +13 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +131 -0
  5. package/dist/core/cookie.d.ts +36 -0
  6. package/dist/core/env.d.ts +69 -0
  7. package/dist/core/logger.d.ts +16 -0
  8. package/dist/core/router.d.ts +72 -0
  9. package/dist/core/serve.d.ts +38 -0
  10. package/dist/core/sse.d.ts +47 -0
  11. package/dist/core/trace.d.ts +95 -0
  12. package/dist/graphql.d.ts +16 -0
  13. package/dist/hub.d.ts +36 -0
  14. package/dist/index.d.ts +61 -0
  15. package/dist/index.js +3963 -0
  16. package/dist/mailer.d.ts +51 -0
  17. package/dist/middleware/compress.d.ts +20 -0
  18. package/dist/middleware/cors.d.ts +25 -0
  19. package/dist/middleware/csrf.d.ts +47 -0
  20. package/dist/middleware/flash.d.ts +90 -0
  21. package/dist/middleware/health.d.ts +24 -0
  22. package/dist/middleware/helmet.d.ts +33 -0
  23. package/dist/middleware/i18n.d.ts +39 -0
  24. package/dist/middleware/rate-limit.d.ts +44 -0
  25. package/dist/middleware/request-id.d.ts +40 -0
  26. package/dist/middleware/static.d.ts +23 -0
  27. package/dist/middleware/theme.d.ts +31 -0
  28. package/dist/middleware/upload.d.ts +55 -0
  29. package/dist/middleware/validate.d.ts +32 -0
  30. package/dist/postgres/client.d.ts +4 -0
  31. package/dist/postgres/index.d.ts +4 -0
  32. package/dist/postgres/module.d.ts +16 -0
  33. package/dist/postgres/schema/columns.d.ts +99 -0
  34. package/dist/postgres/schema/index.d.ts +6 -0
  35. package/dist/postgres/schema/sql.d.ts +22 -0
  36. package/dist/postgres/schema/table.d.ts +141 -0
  37. package/dist/postgres/schema/where.d.ts +29 -0
  38. package/dist/postgres/types.d.ts +49 -0
  39. package/dist/queue/cron.d.ts +9 -0
  40. package/dist/queue/index.d.ts +2 -0
  41. package/dist/queue/types.d.ts +61 -0
  42. package/dist/redis/client.d.ts +2 -0
  43. package/{redis/index.ts → dist/redis/index.d.ts} +2 -2
  44. package/dist/redis/types.d.ts +17 -0
  45. package/dist/test/test-utils.d.ts +193 -0
  46. package/dist/types.d.ts +50 -0
  47. package/package.json +10 -10
  48. package/ai/provider.ts +0 -129
  49. package/ai/stream.ts +0 -63
  50. package/cli.ts +0 -147
  51. package/core/cookie.ts +0 -114
  52. package/core/env.ts +0 -142
  53. package/core/logger.ts +0 -72
  54. package/core/router.ts +0 -795
  55. package/core/serve.ts +0 -294
  56. package/core/sse.ts +0 -85
  57. package/core/trace.ts +0 -146
  58. package/graphql.ts +0 -267
  59. package/hub.ts +0 -133
  60. package/index.ts +0 -71
  61. package/mailer.ts +0 -81
  62. package/middleware/compress.ts +0 -103
  63. package/middleware/cors.ts +0 -81
  64. package/middleware/csrf.ts +0 -112
  65. package/middleware/flash.ts +0 -144
  66. package/middleware/health.ts +0 -44
  67. package/middleware/helmet.ts +0 -98
  68. package/middleware/i18n.ts +0 -175
  69. package/middleware/rate-limit.ts +0 -167
  70. package/middleware/request-id.ts +0 -60
  71. package/middleware/static.ts +0 -149
  72. package/middleware/theme.ts +0 -84
  73. package/middleware/upload.ts +0 -168
  74. package/middleware/validate.ts +0 -186
  75. package/postgres/client.ts +0 -132
  76. package/postgres/index.ts +0 -4
  77. package/postgres/module.ts +0 -37
  78. package/postgres/schema/columns.ts +0 -186
  79. package/postgres/schema/index.ts +0 -36
  80. package/postgres/schema/sql.ts +0 -39
  81. package/postgres/schema/table.ts +0 -548
  82. package/postgres/schema/where.ts +0 -99
  83. package/postgres/types.ts +0 -48
  84. package/queue/cron.ts +0 -90
  85. package/queue/index.ts +0 -654
  86. package/queue/types.ts +0 -60
  87. package/redis/client.ts +0 -24
  88. package/redis/types.ts +0 -28
  89. package/types.ts +0 -78
@@ -1,81 +0,0 @@
1
- import type { Middleware, Context } from '../types.ts'
2
-
3
- /** Options for {@link cors}. */
4
- export interface CORSOptions {
5
- /** Allowed origin(s). Default `'*'`. If `credentials: true`, reflects the request origin. */
6
- origin?: string | string[] | ((origin: string) => string | boolean | undefined)
7
- /** Allowed HTTP methods. Default: `GET, HEAD, PUT, PATCH, POST, DELETE`. */
8
- methods?: string[]
9
- /** Allowed request headers. Default: `Content-Type, Authorization`. */
10
- allowedHeaders?: string[]
11
- /** Exposed response headers. */
12
- exposedHeaders?: string[]
13
- /** Whether to expose `Access-Control-Allow-Credentials`. */
14
- credentials?: boolean
15
- /** `Access-Control-Max-Age` in seconds. */
16
- maxAge?: number
17
- }
18
-
19
- /**
20
- * CORS middleware.
21
- *
22
- * ```ts
23
- * import { cors } from 'weifuwu'
24
- * app.use(cors({ origin: 'https://myapp.com', credentials: true }))
25
- * ```
26
- */
27
- export function cors(options?: CORSOptions): Middleware<Context, Context> {
28
- const opts: Required<Pick<CORSOptions, 'origin' | 'methods' | 'allowedHeaders'>> & CORSOptions = {
29
- origin: '*',
30
- methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
31
- allowedHeaders: ['Content-Type', 'Authorization'],
32
- ...options,
33
- }
34
-
35
- function resolveOrigin(requestOrigin: string): string {
36
- if (typeof opts.origin === 'string') {
37
- if (opts.origin === '*') {
38
- return opts.credentials ? requestOrigin : '*'
39
- }
40
- return opts.origin
41
- }
42
- if (Array.isArray(opts.origin)) {
43
- return opts.origin.includes(requestOrigin) ? requestOrigin : ''
44
- }
45
- const result = opts.origin(requestOrigin)
46
- if (typeof result === 'boolean') return result ? requestOrigin : ''
47
- if (typeof result === 'string') return result
48
- return ''
49
- }
50
-
51
- function setCORSHeaders(res: Response, acao: string): Response {
52
- if (!acao) return res
53
- const headers = new Headers(res.headers)
54
- headers.set('Access-Control-Allow-Origin', acao)
55
- if (opts.credentials) headers.set('Access-Control-Allow-Credentials', 'true')
56
- if (opts.exposedHeaders?.length)
57
- headers.set('Access-Control-Expose-Headers', opts.exposedHeaders.join(', '))
58
- if (acao !== '*') headers.set('Vary', 'Origin')
59
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
60
- }
61
-
62
- return (req, ctx, next) => {
63
- const requestOrigin = req.headers.get('origin') ?? ''
64
- const acao = resolveOrigin(requestOrigin)
65
-
66
- if (req.method === 'OPTIONS' && acao) {
67
- const headers = new Headers()
68
- headers.set('Access-Control-Allow-Origin', acao)
69
- headers.set('Access-Control-Allow-Methods', opts.methods.join(', '))
70
- headers.set('Access-Control-Allow-Headers', opts.allowedHeaders.join(', '))
71
- if (opts.credentials) headers.set('Access-Control-Allow-Credentials', 'true')
72
- if (opts.maxAge != null) headers.set('Access-Control-Max-Age', String(opts.maxAge))
73
- if (acao !== '*') headers.set('Vary', 'Origin')
74
- return new Response(null, { status: 204, headers })
75
- }
76
-
77
- if (!acao) return next(req, ctx)
78
-
79
- return Promise.resolve(next(req, ctx)).then((res) => setCORSHeaders(res, acao))
80
- }
81
- }
@@ -1,112 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */
2
- import type { Context, Middleware } from '../types.ts'
3
- import { getCookies, setCookie } from '../core/cookie.ts'
4
-
5
- // Augment Context with csrf property
6
- declare module '../types.ts' {
7
- interface Context {
8
- csrf: CsrfInjected
9
- }
10
- }
11
-
12
- export interface CsrfInjected {
13
- token: string
14
- }
15
-
16
- /** Options for {@link csrf}. */
17
- /** CSRF protection module — a {@link Middleware} that injects `ctx.csrf`. */
18
- export type CsrfModule = Middleware<Context, Context & CsrfInjected>
19
-
20
- export interface CsrfOptions {
21
- /** Cookie name for CSRF token (default: `'_csrf'`). */
22
- cookie?: string
23
- /** Request header name for CSRF token (default: `'x-csrf-token'`). */
24
- header?: string
25
- /** Form body key for CSRF token (default: `'_csrf'`). */
26
- key?: string
27
- /** HTTP methods to exclude from CSRF protection (default: `['GET', 'HEAD', 'OPTIONS']`). */
28
- excludeMethods?: string[]
29
- }
30
-
31
- /**
32
- * CSRF protection middleware.
33
- *
34
- * On excluded methods (GET, HEAD, OPTIONS), generates a token and stores it
35
- * in a cookie. On other methods, validates the token from header or body
36
- * against the cookie.
37
- *
38
- * Injects `ctx.csrf.token` for use in forms.
39
- *
40
- * ```ts
41
- * import { csrf } from 'weifuwu'
42
- * app.use(csrf())
43
- *
44
- * // In a form:
45
- * app.get('/form', (req, ctx) => {
46
- * return new Response(`
47
- * <form method="POST">
48
- * <input type="hidden" name="_csrf" value="${ctx.csrf.token}" />
49
- * <input type="submit" />
50
- * </form>
51
- * `, { headers: { 'content-type': 'text/html' } })
52
- * })
53
- * ```
54
- */
55
- export function csrf(options?: CsrfOptions): Middleware<Context, Context & CsrfInjected> {
56
- const cookieName = options?.cookie ?? '_csrf'
57
- const headerName = options?.header ?? 'x-csrf-token'
58
- const bodyKey = options?.key ?? '_csrf'
59
- const excluded = new Set(options?.excludeMethods ?? ['GET', 'HEAD', 'OPTIONS'])
60
-
61
- const mw: Middleware<Context, Context & CsrfInjected> = async (req, ctx, next) => {
62
- const method = req.method.toUpperCase()
63
-
64
- if (excluded.has(method)) {
65
- let token = getCookies(req)[cookieName]
66
- if (!token) {
67
- token = crypto.randomUUID()
68
- ;(ctx as any).csrf = { token }
69
- } else {
70
- ;(ctx as any).csrf = { token }
71
- }
72
-
73
- const res = await next(req, ctx as Context & CsrfInjected)
74
- const tokenToSet = (ctx as any).csrf?.token
75
- if (tokenToSet && !getCookies(req)[cookieName]) {
76
- return setCookie(res, cookieName, tokenToSet, {
77
- httpOnly: true,
78
- sameSite: 'strict',
79
- path: '/',
80
- })
81
- }
82
- return res
83
- }
84
-
85
- const cookieToken = getCookies(req)[cookieName]
86
-
87
- let headerToken = req.headers.get(headerName) ?? ''
88
- // Fallback: try to extract from request body
89
- if (
90
- !headerToken &&
91
- (req.method === 'POST' ||
92
- req.method === 'PUT' ||
93
- req.method === 'PATCH' ||
94
- req.method === 'DELETE')
95
- ) {
96
- try {
97
- const body = await req.clone().json()
98
- headerToken = body[bodyKey] ?? ''
99
- } catch (e) {
100
- return new Response('Invalid request body', { status: 400 })
101
- }
102
- }
103
-
104
- if (!cookieToken || !headerToken || cookieToken !== headerToken) {
105
- return new Response('CSRF token mismatch', { status: 403 })
106
- }
107
-
108
- return next(req, ctx as Context & CsrfInjected)
109
- }
110
- mw.__meta = { injects: ['csrf'], depends: [] }
111
- return mw
112
- }
@@ -1,144 +0,0 @@
1
- /**
2
- * Flash message middleware.
3
- *
4
- * Provides a cookie-based flash message system:
5
- * - Read: `ctx.flash.value` parses the incoming flash cookie
6
- * - Write: `ctx.flash.set(data, location)` sets a new flash and redirects
7
- * - Auto-clear: after reading, the flash cookie is cleared from the response
8
- *
9
- * ```ts
10
- * import { flash } from 'weifuwu'
11
- *
12
- * app.use(flash())
13
- *
14
- * // Read flash
15
- * app.get('/', (req, ctx) => {
16
- * const msg = ctx.flash.value // e.g. { type: 'success', text: 'Saved!' }
17
- * })
18
- *
19
- * // Set flash + redirect
20
- * app.post('/save', async (req, ctx) => {
21
- * await save()
22
- * return ctx.flash.set({ type: 'success', text: 'Saved!' }, '/articles')
23
- * })
24
- * ```
25
- */
26
- import type { Context, Middleware } from '../types.ts'
27
- import { getCookies } from '../core/cookie.ts'
28
-
29
- // Augment Context with flash property
30
- declare module '../types.ts' {
31
- interface Context {
32
- flash: FlashInjected
33
- }
34
- }
35
-
36
- /** Flash message module — a {@link Middleware} that injects `ctx.flash`. */
37
- export type FlashModule = Middleware<Context, Context & FlashInjected>
38
-
39
- /** Options for {@link flash}. */
40
- export interface FlashOptions {
41
- /**
42
- * Cookie name to store the flash message.
43
- * @default 'flash'
44
- */
45
- name?: string
46
- }
47
-
48
- /**
49
- * Flash message object injected into `ctx.flash`.
50
- *
51
- * Access the current flash with `.value`, or set a new flash with `.set()`.
52
- * The `.value` is automatically cleared after being read.
53
- */
54
- export interface FlashInjected {
55
- /**
56
- * The flash value read from the incoming cookie.
57
- * `undefined` if no flash cookie is present.
58
- * Automatically cleared after the response is sent.
59
- */
60
- value: unknown
61
- /**
62
- * Set a flash message and return a 302 redirect response.
63
- *
64
- * @param data - Any JSON-serializable value to store as the flash message.
65
- * @param location - Redirect location (defaults to the `Referer` header).
66
- * @returns A 302 Response with a `Set-Cookie` header.
67
- *
68
- * ```ts
69
- * return ctx.flash.set({ type: 'success', text: 'Saved!' }, '/articles')
70
- * ```
71
- */
72
- set: (data: unknown, location?: string) => Response
73
- }
74
-
75
- function makeSetFlash(name: string, location: string) {
76
- return (data: unknown, loc?: string) => {
77
- const finalLoc = loc ?? location
78
- const value = encodeURIComponent(JSON.stringify(data))
79
- return new Response(null, {
80
- status: 302,
81
- headers: {
82
- Location: finalLoc,
83
- 'Set-Cookie': `${name}=${value}; Path=/; SameSite=Lax`,
84
- },
85
- })
86
- }
87
- }
88
-
89
- /**
90
- * Flash message middleware — injects `ctx.flash`.
91
- *
92
- * @param options - Cookie name configuration.
93
- * @returns Middleware that injects `ctx.flash` (`FlashInjected`).
94
- *
95
- * ```ts
96
- * app.use(flash())
97
- *
98
- * // Read
99
- * app.get('/', (req, ctx) => {
100
- * const msg = ctx.flash.value
101
- * })
102
- *
103
- * // Write + redirect
104
- * app.post('/save', async (req, ctx) => {
105
- * return ctx.flash.set({ type: 'success', text: 'Saved!' }, '/articles')
106
- * })
107
- * ```
108
- */
109
- export function flash(
110
- options?: FlashOptions,
111
- ): Middleware<Context, Context & { flash: FlashInjected }> {
112
- const name = options?.name ?? 'flash'
113
-
114
- const mw: Middleware<Context, Context & { flash: FlashInjected }> = async (req, ctx, next) => {
115
- const raw = getCookies(req)[name] ?? null
116
- const referer = req.headers.get('referer') || '/'
117
-
118
- let value: unknown = undefined
119
- if (raw) {
120
- try {
121
- value = JSON.parse(decodeURIComponent(raw))
122
- } catch {
123
- value = raw
124
- }
125
- }
126
-
127
- ctx.flash = {
128
- value,
129
- set: makeSetFlash(name, referer),
130
- } as FlashInjected
131
-
132
- const res = await next(req, ctx as Context & { flash: FlashInjected })
133
-
134
- if (raw) {
135
- const headers = new Headers(res.headers)
136
- headers.append('Set-Cookie', `${name}=; Path=/; Max-Age=0`)
137
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
138
- }
139
-
140
- return res
141
- }
142
- mw.__meta = { injects: ['flash'], depends: [] }
143
- return mw
144
- }
@@ -1,44 +0,0 @@
1
- import type { Handler } from '../types.ts'
2
- import { Router } from '../core/router.ts'
3
-
4
- /** Options for {@link health}. */
5
- export interface HealthOptions {
6
- /** Health check endpoint path (default: `'/__health'`). */
7
- path?: string
8
- /** Async function that throws if the service is unhealthy. Called on each request. */
9
- check?: () => Promise<void>
10
- }
11
-
12
- /**
13
- * Health check endpoint.
14
- *
15
- * Returns 200 with `'OK'` if the check passes, 503 if it fails.
16
- *
17
- * ```ts
18
- * import { health } from 'weifuwu'
19
- *
20
- * app.use(health({
21
- * check: async () => {
22
- * await db.query('SELECT 1')
23
- * },
24
- * }))
25
- * ```
26
- */
27
- export function health(options?: HealthOptions): Router {
28
- const path = options?.path ?? '/__health'
29
- const r = new Router()
30
-
31
- const handler: Handler = async () => {
32
- try {
33
- await options?.check?.()
34
- return new Response('OK', { status: 200 })
35
- } catch {
36
- return new Response('Service Unavailable', { status: 503 })
37
- }
38
- }
39
-
40
- r.get(path, handler)
41
- r.head(path, handler)
42
-
43
- return r
44
- }
@@ -1,98 +0,0 @@
1
- import type { Middleware, Context } from '../types.ts'
2
-
3
- /** Options for {@link helmet}. Set any header to `false` to omit it. */
4
- export interface HelmetOptions {
5
- /** `Content-Security-Policy` header value. */
6
- contentSecurityPolicy?: string | false
7
- /** `Cross-Origin-Embedder-Policy` header value. */
8
- crossOriginEmbedderPolicy?: string | false
9
- /** `Cross-Origin-Opener-Policy` header value. */
10
- crossOriginOpenerPolicy?: string | false
11
- /** `Cross-Origin-Resource-Policy` header value. */
12
- crossOriginResourcePolicy?: string | false
13
- /** `Origin-Agent-Cluster` header value. */
14
- originAgentCluster?: string | false
15
- /** `Referrer-Policy` header value. */
16
- referrerPolicy?: string | false
17
- /** `Strict-Transport-Security` header value. */
18
- strictTransportSecurity?: string | false
19
- /** `X-Content-Type-Options` header value. */
20
- xContentTypeOptions?: string | false
21
- /** `X-DNS-Prefetch-Control` header value. */
22
- xDnsPrefetchControl?: string | false
23
- /** `X-Download-Options` header value. */
24
- xDownloadOptions?: string | false
25
- /** `X-Frame-Options` header value. */
26
- xFrameOptions?: string | false
27
- /** `X-Permitted-Cross-Domain-Policies` header value. */
28
- xPermittedCrossDomainPolicies?: string | false
29
- /** `X-XSS-Protection` header value. */
30
- xXssProtection?: string | false
31
- /** `Permissions-Policy` header value. */
32
- permissionsPolicy?: string | false
33
- }
34
-
35
- /**
36
- * Security headers middleware. Sets sensible defaults for all major security headers.
37
- *
38
- * ```ts
39
- * import { helmet } from 'weifuwu'
40
- * app.use(helmet())
41
- *
42
- * // Customize or disable specific headers
43
- * app.use(helmet({ contentSecurityPolicy: false, xFrameOptions: 'DENY' }))
44
- * ```
45
- */
46
- const HEADER_MAP: Record<string, keyof HelmetOptions> = {
47
- 'Content-Security-Policy': 'contentSecurityPolicy',
48
- 'Cross-Origin-Embedder-Policy': 'crossOriginEmbedderPolicy',
49
- 'Cross-Origin-Opener-Policy': 'crossOriginOpenerPolicy',
50
- 'Cross-Origin-Resource-Policy': 'crossOriginResourcePolicy',
51
- 'Origin-Agent-Cluster': 'originAgentCluster',
52
- 'Referrer-Policy': 'referrerPolicy',
53
- 'Strict-Transport-Security': 'strictTransportSecurity',
54
- 'X-Content-Type-Options': 'xContentTypeOptions',
55
- 'X-DNS-Prefetch-Control': 'xDnsPrefetchControl',
56
- 'X-Download-Options': 'xDownloadOptions',
57
- 'X-Frame-Options': 'xFrameOptions',
58
- 'X-Permitted-Cross-Domain-Policies': 'xPermittedCrossDomainPolicies',
59
- 'X-XSS-Protection': 'xXssProtection',
60
- 'Permissions-Policy': 'permissionsPolicy',
61
- }
62
-
63
- export function helmet(options?: HelmetOptions): Middleware<Context, Context> {
64
- const opts = { ...DEFAULTS, ...options } as HelmetOptions
65
-
66
- const headers = new Headers()
67
- for (const [header, key] of Object.entries(HEADER_MAP)) {
68
- const val = opts[key]
69
- if (val !== false && val !== undefined) headers.set(header, val)
70
- }
71
-
72
- return async (req, ctx, next) => {
73
- const res = await next(req, ctx)
74
- const h = new Headers(res.headers)
75
- for (const [k, v] of headers) {
76
- if (!h.has(k)) h.set(k, v)
77
- }
78
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h })
79
- }
80
- }
81
-
82
- const DEFAULTS: HelmetOptions = {
83
- contentSecurityPolicy:
84
- "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
85
- crossOriginEmbedderPolicy: 'require-corp',
86
- crossOriginOpenerPolicy: 'same-origin',
87
- crossOriginResourcePolicy: 'same-origin',
88
- originAgentCluster: '?1',
89
- referrerPolicy: 'no-referrer',
90
- strictTransportSecurity: 'max-age=15552000; includeSubDomains',
91
- xContentTypeOptions: 'nosniff',
92
- xDnsPrefetchControl: 'off',
93
- xDownloadOptions: 'noopen',
94
- xFrameOptions: 'SAMEORIGIN',
95
- xPermittedCrossDomainPolicies: 'none',
96
- xXssProtection: '0',
97
- permissionsPolicy: 'camera=(),display-capture=(),fullscreen=(),geolocation=(),microphone=()',
98
- }
@@ -1,175 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { readFile, stat } from 'node:fs/promises'
3
- import { join, resolve } from 'node:path'
4
- import type { Context, Middleware } from '../types.ts'
5
- import { getCookies } from '../core/cookie.ts'
6
- import { Router } from '../core/router.ts'
7
-
8
- // Augment Context with i18n property
9
- declare module '../types.ts' {
10
- interface Context {
11
- i18n: I18nInjected
12
- }
13
- }
14
-
15
- export interface I18nInjected {
16
- locale: string
17
- messages?: Record<string, unknown>
18
- t: (key: string, params?: Record<string, string>, fallback?: string) => string
19
- set?: (value: string, loc?: string) => Response
20
- }
21
-
22
- export interface I18nOptions {
23
- /** Default locale (default: 'en'). */
24
- default?: string
25
- /** Directory containing `{locale}.json` translation files. */
26
- dir?: string
27
- /** Inline translation messages keyed by locale. */
28
- messages?: Record<string, Record<string, unknown>>
29
- /** Cookie name for locale (default: 'locale'). Set empty to disable. */
30
- cookie?: string
31
- /** Whether to detect locale from Accept-Language header (default: true). */
32
- fromAcceptLanguage?: boolean
33
- }
34
-
35
- const DEFAULTS = {
36
- default: 'en',
37
- cookie: 'locale',
38
- fromAcceptLanguage: true,
39
- }
40
-
41
- function translate(
42
- msgs: Record<string, unknown>,
43
- key: string,
44
- params?: Record<string, string>,
45
- fallback?: string,
46
- ): string {
47
- const msg = key.split('.').reduce((o: any, k: string) => o?.[k], msgs)
48
- if (msg === undefined || msg === null) return fallback ?? key
49
- if (!params) return String(msg)
50
- let result = String(msg)
51
- for (const [k, v] of Object.entries(params)) {
52
- result = result.replace(`{${k}}`, v)
53
- }
54
- return result
55
- }
56
-
57
- /**
58
- * i18n module. Returns a Router with an attached `.middleware()` method.
59
- *
60
- * ```ts
61
- * const l = i18n({ dir: './locales' })
62
- * app.use(l.middleware()) // → ctx.i18n = { locale, t, set }
63
- * app.use('/', l) // → GET /__lang/:locale (switch route)
64
- * ```
65
- */
66
- export interface I18nModule extends Router {
67
- /** Middleware that injects `ctx.i18n = { locale, t, set }`. */
68
- middleware: () => Middleware<Context, Context & I18nInjected>
69
- }
70
-
71
- export function i18n(options?: I18nOptions): I18nModule {
72
- const opts = { ...DEFAULTS, ...options }
73
- const dir = opts.dir ? resolve(opts.dir) : undefined
74
- const cache = new Map<string, Record<string, unknown>>()
75
-
76
- function validLocale(locale: string): boolean {
77
- return /^[\w-]+$/.test(locale) && !locale.includes('..')
78
- }
79
-
80
- async function loadMessages(locale: string): Promise<Record<string, unknown>> {
81
- // Check inline messages first
82
- if (opts.messages?.[locale] && Object.keys(opts.messages[locale]).length > 0) {
83
- cache.set(locale, opts.messages[locale])
84
- return opts.messages[locale]
85
- }
86
- // Then check file system
87
- if (!dir || !validLocale(locale)) return {}
88
- const cached = cache.get(locale)
89
- if (cached) return cached
90
- const filePath = join(dir, `${locale}.json`)
91
- try {
92
- await stat(filePath)
93
- const content = await readFile(filePath, 'utf-8')
94
- const data = JSON.parse(content) as Record<string, unknown>
95
- cache.set(locale, data)
96
- return data
97
- } catch {
98
- /* file not found */
99
- }
100
- // Fallback: zh-CN → zh
101
- const short = locale.split('-')[0]
102
- if (short !== locale) {
103
- const fallback = cache.get(short) || (await loadMessages(short))
104
- if (fallback && Object.keys(fallback).length > 0) {
105
- cache.set(locale, fallback)
106
- return fallback
107
- }
108
- }
109
- return {}
110
- }
111
-
112
- function detectLocale(req: Request): string {
113
- if (opts.cookie) {
114
- const fromCookie = getCookies(req)[opts.cookie]
115
- if (fromCookie && validLocale(fromCookie)) return fromCookie
116
- }
117
- if (opts.fromAcceptLanguage) {
118
- const fromHeader = req.headers.get('Accept-Language')?.split(',')[0]?.trim()
119
- if (fromHeader && validLocale(fromHeader)) return fromHeader
120
- }
121
- return opts.default
122
- }
123
-
124
- const mw: Middleware<Context, Context & I18nInjected> = async (req, ctx, next) => {
125
- const locale = detectLocale(req)
126
- const msgs = await loadMessages(locale)
127
-
128
- ;(ctx as Context & I18nInjected).i18n = {
129
- locale,
130
- messages: msgs,
131
- t: (key: string, params?: Record<string, string>, fallback?: string) =>
132
- translate(msgs, key, params, fallback),
133
- set: (value: string, loc?: string) => {
134
- const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`
135
- const location = loc ?? (req.headers.get('referer') || '/')
136
- return new Response(null, {
137
- status: 302,
138
- headers: { Location: location, 'Set-Cookie': cookie },
139
- })
140
- },
141
- }
142
-
143
- return next(req, ctx as Context & I18nInjected)
144
- }
145
- mw.__meta = { injects: ['i18n'], depends: [] }
146
-
147
- class I18nRouter extends Router {
148
- middleware() {
149
- return mw
150
- }
151
- }
152
-
153
- const router = new I18nRouter()
154
- router.get('/__lang/:locale', async (req) => {
155
- const url = new URL(req.url)
156
- const value = url.pathname.split('/__lang/')[1] ?? ''
157
- const cookie = `${opts.cookie}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`
158
- const messages = await loadMessages(value)
159
- const accept = req.headers.get('accept') ?? ''
160
- if (accept.includes('application/json')) {
161
- return Response.json(
162
- {
163
- ok: true,
164
- locale: value,
165
- messages: Object.keys(messages).length > 0 ? messages : undefined,
166
- },
167
- { headers: { 'Set-Cookie': cookie } },
168
- )
169
- }
170
- const referer = req.headers.get('referer') || '/'
171
- return new Response(null, { status: 302, headers: { Location: referer, 'Set-Cookie': cookie } })
172
- })
173
-
174
- return router
175
- }