@gravito/cosmos 3.0.1 → 3.2.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.
Files changed (55) hide show
  1. package/MIGRATION.md +331 -0
  2. package/README.md +105 -45
  3. package/README.zh-TW.md +102 -22
  4. package/docs/plans/01-performance.md +187 -0
  5. package/docs/plans/02-architecture.md +309 -0
  6. package/docs/plans/03-api-enhancement.md +345 -0
  7. package/docs/plans/04-testing.md +431 -0
  8. package/docs/plans/README.md +47 -0
  9. package/ion/src/index.js +1179 -1138
  10. package/package.json +22 -6
  11. package/scripts/check-coverage.ts +64 -0
  12. package/src/HMRWatcher.ts +305 -0
  13. package/src/I18nService.ts +715 -91
  14. package/src/index.edge.ts +35 -0
  15. package/src/index.node.ts +20 -0
  16. package/src/index.ts +39 -6
  17. package/src/loader.ts +64 -14
  18. package/src/loaders/ChainedLoader.ts +117 -0
  19. package/src/loaders/CloudflareKVLoader.ts +194 -0
  20. package/src/loaders/EdgeKVLoader.ts +248 -0
  21. package/src/loaders/FileSystemLoader.ts +125 -0
  22. package/src/loaders/MemoryLoader.ts +161 -0
  23. package/src/loaders/RemoteLoader.ts +235 -0
  24. package/src/loaders/TranslationLoader.ts +98 -0
  25. package/src/loaders/VercelKVLoader.ts +192 -0
  26. package/src/runtime/detector.ts +97 -0
  27. package/src/runtime/path-utils.ts +169 -0
  28. package/tests/helpers/factory.ts +41 -0
  29. package/tests/performance/translate.bench.ts +27 -0
  30. package/tests/unit/api.test.ts +37 -0
  31. package/tests/unit/detector.test.ts +65 -0
  32. package/tests/unit/edge-kv-loader.test.ts +202 -0
  33. package/tests/unit/edge.test.ts +100 -0
  34. package/tests/unit/fallback.test.ts +66 -0
  35. package/tests/unit/hmr.test.ts +255 -0
  36. package/tests/unit/lazy.test.ts +35 -0
  37. package/tests/unit/loader.test.ts +72 -0
  38. package/tests/unit/loaders.test.ts +332 -0
  39. package/tests/{manager.test.ts → unit/manager.test.ts} +1 -1
  40. package/tests/unit/memory-loader.test.ts +130 -0
  41. package/tests/unit/path-utils.test.ts +135 -0
  42. package/tests/unit/plural.test.ts +58 -0
  43. package/tests/unit/runtime-detector.test.ts +86 -0
  44. package/tests/{service.test.ts → unit/service.test.ts} +2 -2
  45. package/tsconfig.json +12 -24
  46. package/.turbo/turbo-build.log +0 -20
  47. package/.turbo/turbo-test$colon$ci.log +0 -35
  48. package/.turbo/turbo-test$colon$coverage.log +0 -35
  49. package/.turbo/turbo-test.log +0 -27
  50. package/.turbo/turbo-typecheck.log +0 -2
  51. package/dist/index.cjs +0 -309
  52. package/dist/index.d.cts +0 -274
  53. package/dist/index.d.ts +0 -274
  54. package/dist/index.js +0 -277
  55. package/tests/loader.test.ts +0 -44
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @file packages/cosmos/src/loaders/RemoteLoader.ts
3
+ * @module @gravito/cosmos/loaders
4
+ * @description 從遠端 HTTP API 載入翻譯資源的實現
5
+ */
6
+
7
+ import type { TranslationMap } from '../I18nService'
8
+ import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
9
+
10
+ /**
11
+ * 遠端載入器配置
12
+ *
13
+ * @public
14
+ * @since 3.1.0
15
+ */
16
+ export interface RemoteLoaderConfig extends LoaderConfig {
17
+ /**
18
+ * 遠端 API 的 URL 模板
19
+ *
20
+ * 使用 `:locale` 作為語言代碼的佔位符
21
+ *
22
+ * @example 'https://cms.example.com/i18n/:locale'
23
+ */
24
+ url: string
25
+
26
+ /**
27
+ * HTTP 請求標頭
28
+ *
29
+ * @example { 'Authorization': 'Bearer token', 'Accept': 'application/json' }
30
+ */
31
+ headers?: Record<string, string>
32
+
33
+ /**
34
+ * 請求超時時間 (毫秒)
35
+ *
36
+ * @default 10000 (10 秒)
37
+ */
38
+ timeout?: number
39
+
40
+ /**
41
+ * 重試次數
42
+ *
43
+ * @default 3
44
+ */
45
+ retries?: number
46
+
47
+ /**
48
+ * 重試延遲 (毫秒)
49
+ *
50
+ * 使用指數退避策略: delay * (2 ^ attempt)
51
+ *
52
+ * @default 1000 (1 秒)
53
+ */
54
+ retryDelay?: number
55
+
56
+ /**
57
+ * 是否啟用 ETag 快取
58
+ *
59
+ * 啟用後,會在後續請求中發送 If-None-Match 標頭
60
+ * 如果伺服器返回 304 Not Modified,則使用快取的翻譯
61
+ *
62
+ * @default true
63
+ */
64
+ etagCache?: boolean
65
+ }
66
+
67
+ /**
68
+ * 遠端翻譯載入器
69
+ *
70
+ * 從遠端 HTTP API 載入翻譯資源
71
+ * 支援 ETag 快取、重試機制、超時處理
72
+ *
73
+ * @public
74
+ * @since 3.1.0
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * const loader = new RemoteLoader({
79
+ * url: 'https://api.example.com/i18n/:locale',
80
+ * headers: { 'Authorization': 'Bearer your-token' },
81
+ * timeout: 5000,
82
+ * retries: 3,
83
+ * etagCache: true
84
+ * })
85
+ *
86
+ * const translations = await loader.load('zh-TW')
87
+ * // GET https://api.example.com/i18n/zh-TW
88
+ * ```
89
+ */
90
+ export class RemoteLoader implements TranslationLoaderChain {
91
+ public readonly name: string
92
+ private url: string
93
+ private headers: Record<string, string>
94
+ private timeout: number
95
+ private retries: number
96
+ private retryDelay: number
97
+ private etagCache: boolean
98
+ private fallbackLoader?: TranslationLoader
99
+
100
+ /** ETag 快取:locale -> etag */
101
+ private etagMap = new Map<string, string>()
102
+ /** 翻譯快取:locale -> translations (用於 ETag 304 回應) */
103
+ private translationCache = new Map<string, TranslationMap>()
104
+
105
+ /**
106
+ * 建立遠端載入器實例
107
+ *
108
+ * @param config - 載入器配置
109
+ */
110
+ constructor(config: RemoteLoaderConfig) {
111
+ this.name = config.name || 'RemoteLoader'
112
+ this.url = config.url
113
+ this.headers = config.headers || {}
114
+ this.timeout = config.timeout ?? 10000
115
+ this.retries = config.retries ?? 3
116
+ this.retryDelay = config.retryDelay ?? 1000
117
+ this.etagCache = config.etagCache ?? true
118
+ }
119
+
120
+ /**
121
+ * 載入指定語言的翻譯資源
122
+ *
123
+ * @param locale - 語言代碼
124
+ * @returns 翻譯資源,載入失敗則返回 null
125
+ */
126
+ async load(locale: string): Promise<TranslationMap | null> {
127
+ let _lastError: Error | null = null
128
+
129
+ // 重試邏輯
130
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
131
+ try {
132
+ const result = await this.fetchWithTimeout(locale)
133
+ return result
134
+ } catch (error) {
135
+ _lastError = error as Error
136
+
137
+ // 如果不是最後一次嘗試,等待後重試
138
+ if (attempt < this.retries) {
139
+ const delay = this.retryDelay * 2 ** attempt
140
+ await this.sleep(delay)
141
+ }
142
+ }
143
+ }
144
+
145
+ // 所有重試都失敗,嘗試備用載入器
146
+ if (this.fallbackLoader) {
147
+ return this.fallbackLoader.load(locale)
148
+ }
149
+
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * 發送 HTTP 請求並處理 ETag 快取
155
+ *
156
+ * @param locale - 語言代碼
157
+ * @returns 翻譯資源
158
+ */
159
+ private async fetchWithTimeout(locale: string): Promise<TranslationMap | null> {
160
+ const url = this.url.replace(':locale', locale)
161
+ const controller = new AbortController()
162
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout)
163
+
164
+ try {
165
+ const headers: Record<string, string> = { ...this.headers }
166
+
167
+ // 如果啟用 ETag 快取且有快取的 ETag,加入 If-None-Match 標頭
168
+ if (this.etagCache && this.etagMap.has(locale)) {
169
+ headers['If-None-Match'] = this.etagMap.get(locale)!
170
+ }
171
+
172
+ const response = await fetch(url, {
173
+ method: 'GET',
174
+ headers,
175
+ signal: controller.signal,
176
+ })
177
+
178
+ // 處理 304 Not Modified
179
+ if (response.status === 304) {
180
+ const cached = this.translationCache.get(locale)
181
+ if (cached) {
182
+ return cached
183
+ }
184
+ }
185
+
186
+ // 處理錯誤狀態碼
187
+ if (!response.ok) {
188
+ throw new Error(`HTTP error! status: ${response.status}`)
189
+ }
190
+
191
+ const data = (await response.json()) as TranslationMap
192
+
193
+ // 儲存 ETag 和翻譯快取
194
+ if (this.etagCache) {
195
+ const etag = response.headers.get('ETag')
196
+ if (etag) {
197
+ this.etagMap.set(locale, etag)
198
+ this.translationCache.set(locale, data)
199
+ }
200
+ }
201
+
202
+ return data
203
+ } finally {
204
+ clearTimeout(timeoutId)
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 延遲函數
210
+ *
211
+ * @param ms - 延遲時間 (毫秒)
212
+ */
213
+ private sleep(ms: number): Promise<void> {
214
+ return new Promise((resolve) => setTimeout(resolve, ms))
215
+ }
216
+
217
+ /**
218
+ * 設定備用載入器
219
+ *
220
+ * @param loader - 備用載入器
221
+ * @returns 當前實例,支援鏈式調用
222
+ */
223
+ fallback(loader: TranslationLoader): TranslationLoaderChain {
224
+ this.fallbackLoader = loader
225
+ return this
226
+ }
227
+
228
+ /**
229
+ * 清除 ETag 和翻譯快取
230
+ */
231
+ clearCache(): void {
232
+ this.etagMap.clear()
233
+ this.translationCache.clear()
234
+ }
235
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @file packages/cosmos/src/loaders/TranslationLoader.ts
3
+ * @module @gravito/cosmos/loaders
4
+ * @description 抽象的翻譯載入器介面,支援多種翻譯來源
5
+ */
6
+
7
+ import type { TranslationMap } from '../I18nService'
8
+
9
+ /**
10
+ * 翻譯載入器介面
11
+ *
12
+ * 定義統一的載入介面,支援多種實現:
13
+ * - FileSystemLoader: 從檔案系統載入
14
+ * - RemoteLoader: 從 HTTP API 載入
15
+ * - DatabaseLoader: 從資料庫載入
16
+ * - EdgeLoader: Edge Runtime 相容的載入器
17
+ *
18
+ * @public
19
+ * @since 3.1.0
20
+ */
21
+ export interface TranslationLoader {
22
+ /**
23
+ * 載入器名稱,用於識別和除錯
24
+ */
25
+ readonly name: string
26
+
27
+ /**
28
+ * 載入指定語言的翻譯資源
29
+ *
30
+ * @param locale - 要載入的語言代碼 (如 'en', 'zh-TW')
31
+ * @returns 翻譯資源,如果載入失敗則返回 null
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const translations = await loader.load('zh-TW')
36
+ * if (translations) {
37
+ * console.log(translations.welcome) // "歡迎"
38
+ * }
39
+ * ```
40
+ */
41
+ load(locale: string): Promise<TranslationMap | null>
42
+ }
43
+
44
+ /**
45
+ * 支援鏈式組合的翻譯載入器介面
46
+ *
47
+ * 允許多個載入器組合,實現降級策略:
48
+ * 主要載入器失敗時,自動嘗試備用載入器
49
+ *
50
+ * @public
51
+ * @since 3.1.0
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const loader = new FileSystemLoader(config)
56
+ * .fallback(new RemoteLoader(remoteConfig))
57
+ * .fallback(new MemoryLoader(fallbackTranslations))
58
+ *
59
+ * // 載入順序: FileSystem -> Remote -> Memory
60
+ * const translations = await loader.load('zh-TW')
61
+ * ```
62
+ */
63
+ export interface TranslationLoaderChain extends TranslationLoader {
64
+ /**
65
+ * 設定備用載入器
66
+ *
67
+ * 當當前載入器失敗時,會嘗試使用備用載入器
68
+ *
69
+ * @param loader - 備用載入器
70
+ * @returns 鏈式載入器實例,支援繼續串接
71
+ */
72
+ fallback(loader: TranslationLoader): TranslationLoaderChain
73
+ }
74
+
75
+ /**
76
+ * 載入器配置的基礎介面
77
+ *
78
+ * 所有載入器都應該接受的通用配置選項
79
+ *
80
+ * @public
81
+ * @since 3.1.0
82
+ */
83
+ export interface LoaderConfig {
84
+ /**
85
+ * 載入器名稱 (可選)
86
+ */
87
+ name?: string
88
+
89
+ /**
90
+ * 快取策略 (可選)
91
+ */
92
+ cache?: {
93
+ /** 是否啟用快取 */
94
+ enabled: boolean
95
+ /** 快取過期時間 (毫秒) */
96
+ ttl?: number
97
+ }
98
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @file packages/cosmos/src/loaders/VercelKVLoader.ts
3
+ * @module @gravito/cosmos/loaders
4
+ * @description Vercel KV 載入器
5
+ */
6
+
7
+ import type { TranslationMap } from '../I18nService'
8
+ import type { KVStorage } from './EdgeKVLoader'
9
+ import { EdgeKVLoader } from './EdgeKVLoader'
10
+ import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
11
+
12
+ /**
13
+ * Vercel KV 適配器
14
+ *
15
+ * 將 @vercel/kv 適配到 KVStorage 介面
16
+ *
17
+ * @internal
18
+ * @since 3.2.0
19
+ */
20
+ export class VercelKVAdapter implements KVStorage {
21
+ constructor(private kv: any) {} // @vercel/kv 型別
22
+
23
+ async get(key: string): Promise<string | null> {
24
+ return this.kv.get(key)
25
+ }
26
+
27
+ async put(key: string, value: string): Promise<void> {
28
+ await this.kv.set(key, value)
29
+ }
30
+
31
+ async delete(key: string): Promise<void> {
32
+ await this.kv.del(key)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Vercel KV 載入器配置
38
+ *
39
+ * @public
40
+ * @since 3.2.0
41
+ */
42
+ export interface VercelKVLoaderConfig extends LoaderConfig {
43
+ /**
44
+ * Vercel KV 實例
45
+ *
46
+ * 從 @vercel/kv 導入
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * import { kv } from '@vercel/kv'
51
+ *
52
+ * const loader = new VercelKVLoader({ kv })
53
+ * ```
54
+ */
55
+ kv: any // @vercel/kv
56
+
57
+ /**
58
+ * Key 前綴
59
+ *
60
+ * @default 'i18n'
61
+ */
62
+ prefix?: string
63
+
64
+ /**
65
+ * Key 模板
66
+ *
67
+ * @default ':prefix::locale'
68
+ */
69
+ keyTemplate?: string
70
+ }
71
+
72
+ /**
73
+ * Vercel KV 載入器
74
+ *
75
+ * 從 Vercel KV (基於 Redis) 載入翻譯資源
76
+ *
77
+ * @public
78
+ * @since 3.2.0
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * // app/api/route.ts (Vercel Edge Function)
83
+ * import { OrbitCosmos, VercelKVLoader } from '@gravito/cosmos/edge'
84
+ * import { kv } from '@vercel/kv'
85
+ *
86
+ * const cosmos = new OrbitCosmos({
87
+ * defaultLocale: 'en',
88
+ * supportedLocales: ['en', 'zh-TW'],
89
+ * loaders: [
90
+ * new VercelKVLoader({
91
+ * kv,
92
+ * prefix: 'i18n'
93
+ * })
94
+ * ]
95
+ * })
96
+ *
97
+ * export async function GET() {
98
+ * const i18n = cosmos.clone('zh-TW')
99
+ * await i18n.ensureLocale('zh-TW')
100
+ * return new Response(i18n.t('welcome'))
101
+ * }
102
+ * ```
103
+ *
104
+ * ## 上傳翻譯到 Vercel KV
105
+ *
106
+ * 使用 Vercel CLI 或 API 上傳翻譯:
107
+ *
108
+ * ```typescript
109
+ * // scripts/upload-translations.ts
110
+ * import { kv } from '@vercel/kv'
111
+ * import { readFileSync } from 'fs'
112
+ *
113
+ * const locales = ['en', 'zh-TW', 'ja']
114
+ *
115
+ * for (const locale of locales) {
116
+ * const translations = JSON.parse(
117
+ * readFileSync(`./lang/${locale}.json`, 'utf-8')
118
+ * )
119
+ * await kv.set(`i18n:${locale}`, JSON.stringify(translations))
120
+ * console.log(`Uploaded ${locale}`)
121
+ * }
122
+ * ```
123
+ *
124
+ * ## 特點
125
+ *
126
+ * - **基於 Redis**: 高效能的記憶體資料庫
127
+ * - **全球複製**: 資料複製到多個區域
128
+ * - **低延遲**: Edge 環境快速存取
129
+ * - **動態更新**: 即時更新翻譯
130
+ * - **TTL 支援**: 可設定過期時間
131
+ *
132
+ * ## 環境變數
133
+ *
134
+ * 需要在 Vercel 專案中設定:
135
+ *
136
+ * ```env
137
+ * KV_URL=your-kv-url
138
+ * KV_REST_API_URL=your-api-url
139
+ * KV_REST_API_TOKEN=your-api-token
140
+ * KV_REST_API_READ_ONLY_TOKEN=your-read-only-token
141
+ * ```
142
+ */
143
+ export class VercelKVLoader implements TranslationLoaderChain {
144
+ private loader: EdgeKVLoader
145
+
146
+ /**
147
+ * 建立 Vercel KV 載入器實例
148
+ *
149
+ * @param config - 載入器配置
150
+ */
151
+ constructor(config: VercelKVLoaderConfig) {
152
+ const adapter = new VercelKVAdapter(config.kv)
153
+ this.loader = new EdgeKVLoader({
154
+ storage: adapter,
155
+ prefix: config.prefix,
156
+ keyTemplate: config.keyTemplate,
157
+ name: config.name || 'VercelKVLoader',
158
+ })
159
+ }
160
+
161
+ get name(): string {
162
+ return this.loader.name
163
+ }
164
+
165
+ async load(locale: string): Promise<TranslationMap | null> {
166
+ return this.loader.load(locale)
167
+ }
168
+
169
+ fallback(loader: TranslationLoader): TranslationLoaderChain {
170
+ this.loader.fallback(loader)
171
+ return this
172
+ }
173
+
174
+ /**
175
+ * 儲存翻譯資源到 Vercel KV
176
+ *
177
+ * @param locale - 語言代碼
178
+ * @param translations - 翻譯資源
179
+ */
180
+ async put(locale: string, translations: TranslationMap): Promise<void> {
181
+ return this.loader.put(locale, translations)
182
+ }
183
+
184
+ /**
185
+ * 從 Vercel KV 刪除翻譯資源
186
+ *
187
+ * @param locale - 語言代碼
188
+ */
189
+ async delete(locale: string): Promise<void> {
190
+ return this.loader.delete(locale)
191
+ }
192
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file packages/cosmos/src/runtime/detector.ts
3
+ * @module @gravito/cosmos/runtime
4
+ * @description 運行環境檢測工具
5
+ */
6
+
7
+ /**
8
+ * 運行環境類型
9
+ *
10
+ * @public
11
+ * @since 3.2.0
12
+ */
13
+ export type RuntimeEnvironment = 'node' | 'edge' | 'unknown'
14
+
15
+ /**
16
+ * 檢測當前運行環境
17
+ *
18
+ * 支援的環境:
19
+ * - Node.js (包含 Bun)
20
+ * - Cloudflare Workers
21
+ * - Vercel Edge Functions
22
+ * - Deno Deploy
23
+ * - 其他 Edge Runtime
24
+ *
25
+ * @returns 當前運行環境類型
26
+ * @public
27
+ * @since 3.2.0
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * import { detectRuntime } from '@gravito/cosmos/runtime'
32
+ *
33
+ * const runtime = detectRuntime()
34
+ * if (runtime === 'node') {
35
+ * // 使用 Node.js 特定功能
36
+ * } else if (runtime === 'edge') {
37
+ * // 使用 Edge Runtime 功能
38
+ * }
39
+ * ```
40
+ */
41
+ export function detectRuntime(): RuntimeEnvironment {
42
+ // Cloudflare Workers
43
+ // 檢查 Cloudflare Workers 特有的全域物件
44
+ if (
45
+ typeof globalThis.caches !== 'undefined' &&
46
+ typeof (globalThis as any).WebSocketPair !== 'undefined'
47
+ ) {
48
+ return 'edge'
49
+ }
50
+
51
+ // Vercel Edge Functions
52
+ // 檢查 Vercel Edge Runtime 特有的全域變數
53
+ if (typeof (globalThis as any).EdgeRuntime !== 'undefined') {
54
+ return 'edge'
55
+ }
56
+
57
+ // Deno Deploy
58
+ // 檢查 Deno 特有的全域物件
59
+ if (typeof (globalThis as any).Deno !== 'undefined') {
60
+ return 'edge'
61
+ }
62
+
63
+ // Node.js (包含 Bun)
64
+ // 檢查 process 物件和 Node.js 版本
65
+ if (typeof process !== 'undefined' && process.versions?.node !== undefined) {
66
+ return 'node'
67
+ }
68
+
69
+ // Bun (當 process.versions.node 不存在時)
70
+ if (typeof process !== 'undefined' && (process.versions as any)?.bun !== undefined) {
71
+ return 'node' // Bun 相容 Node.js API
72
+ }
73
+
74
+ return 'unknown'
75
+ }
76
+
77
+ /**
78
+ * 檢查是否在 Node.js 環境
79
+ *
80
+ * @returns 是否為 Node.js 環境
81
+ * @public
82
+ * @since 3.2.0
83
+ */
84
+ export function isNode(): boolean {
85
+ return detectRuntime() === 'node'
86
+ }
87
+
88
+ /**
89
+ * 檢查是否在 Edge Runtime 環境
90
+ *
91
+ * @returns 是否為 Edge Runtime 環境
92
+ * @public
93
+ * @since 3.2.0
94
+ */
95
+ export function isEdge(): boolean {
96
+ return detectRuntime() === 'edge'
97
+ }