@gravito/cosmos 3.1.0 → 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.
@@ -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
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @file packages/cosmos/src/runtime/path-utils.ts
3
+ * @module @gravito/cosmos/runtime
4
+ * @description Edge-compatible path 工具函數
5
+ *
6
+ * 提供與 Node.js path 模組相似的功能,但不依賴 Node.js API
7
+ * 適用於 Edge Runtime 環境
8
+ */
9
+
10
+ /**
11
+ * Edge-compatible path 工具集
12
+ *
13
+ * @public
14
+ * @since 3.2.0
15
+ */
16
+ export const pathUtils = {
17
+ /**
18
+ * 將多個路徑片段連接成一個路徑
19
+ *
20
+ * @param parts - 路徑片段
21
+ * @returns 連接後的路徑
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * pathUtils.join('foo', 'bar', 'baz.txt') // 'foo/bar/baz.txt'
26
+ * pathUtils.join('/foo', '/bar/') // '/foo/bar'
27
+ * pathUtils.join('', 'foo', '', 'bar') // 'foo/bar'
28
+ * ```
29
+ */
30
+ join(...parts: string[]): string {
31
+ return parts
32
+ .filter(Boolean) // 移除空字串
33
+ .join('/')
34
+ .replace(/\/+/g, '/') // 將多個斜線替換為單個斜線
35
+ .replace(/\/$/, '') // 移除尾部斜線
36
+ .replace(/^\/+/, '/') // 確保開頭只有一個斜線(如果有的話)
37
+ },
38
+
39
+ /**
40
+ * 返回路徑的最後一部分
41
+ *
42
+ * @param path - 路徑
43
+ * @param ext - 要移除的副檔名(可選)
44
+ * @returns 路徑的基本名稱
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * pathUtils.basename('/foo/bar/baz.txt') // 'baz.txt'
49
+ * pathUtils.basename('/foo/bar/baz.txt', '.txt') // 'baz'
50
+ * pathUtils.basename('/foo/bar/') // 'bar'
51
+ * ```
52
+ */
53
+ basename(path: string, ext?: string): string {
54
+ // 移除尾部斜線
55
+ const normalized = path.replace(/\/$/, '')
56
+ // 取得最後一個片段
57
+ const base = normalized.split('/').pop() || ''
58
+
59
+ if (ext && base.endsWith(ext)) {
60
+ return base.slice(0, -ext.length)
61
+ }
62
+ return base
63
+ },
64
+
65
+ /**
66
+ * 返回路徑的目錄部分
67
+ *
68
+ * @param path - 路徑
69
+ * @returns 目錄路徑
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * pathUtils.dirname('/foo/bar/baz.txt') // '/foo/bar'
74
+ * pathUtils.dirname('/foo') // '/'
75
+ * pathUtils.dirname('foo/bar') // 'foo'
76
+ * ```
77
+ */
78
+ dirname(path: string): string {
79
+ const parts = path.split('/')
80
+ parts.pop() // 移除最後一個片段
81
+ const result = parts.join('/')
82
+ return result || (path.startsWith('/') ? '/' : '.')
83
+ },
84
+
85
+ /**
86
+ * 返回路徑的副檔名
87
+ *
88
+ * @param path - 路徑
89
+ * @returns 副檔名(包含點號)
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * pathUtils.extname('index.html') // '.html'
94
+ * pathUtils.extname('index.coffee.md') // '.md'
95
+ * pathUtils.extname('index.') // '.'
96
+ * pathUtils.extname('index') // ''
97
+ * ```
98
+ */
99
+ extname(path: string): string {
100
+ const match = path.match(/\.[^./]*$/)
101
+ return match ? match[0] : ''
102
+ },
103
+
104
+ /**
105
+ * 檢查路徑是否為絕對路徑
106
+ *
107
+ * @param path - 路徑
108
+ * @returns 是否為絕對路徑
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * pathUtils.isAbsolute('/foo/bar') // true
113
+ * pathUtils.isAbsolute('foo/bar') // false
114
+ * pathUtils.isAbsolute('C:/foo/bar') // true (Windows)
115
+ * ```
116
+ */
117
+ isAbsolute(path: string): boolean {
118
+ // Unix-style absolute path
119
+ if (path.startsWith('/')) {
120
+ return true
121
+ }
122
+ // Windows-style absolute path (C:/, D:/, etc.)
123
+ if (/^[a-zA-Z]:[\\/]/.test(path)) {
124
+ return true
125
+ }
126
+ return false
127
+ },
128
+
129
+ /**
130
+ * 正規化路徑
131
+ *
132
+ * 解析 `.` 和 `..` 片段
133
+ *
134
+ * @param path - 路徑
135
+ * @returns 正規化後的路徑
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * pathUtils.normalize('/foo/bar//baz/./qux/../quux') // '/foo/bar/baz/quux'
140
+ * pathUtils.normalize('foo/../bar') // 'bar'
141
+ * ```
142
+ */
143
+ normalize(path: string): string {
144
+ const isAbs = this.isAbsolute(path)
145
+ const parts = path.split('/').filter(Boolean)
146
+ const result: string[] = []
147
+
148
+ for (const part of parts) {
149
+ if (part === '.') {
150
+ } else if (part === '..') {
151
+ // 返回上一級目錄
152
+ if (result.length > 0 && result[result.length - 1] !== '..') {
153
+ result.pop()
154
+ } else if (!isAbs) {
155
+ // 相對路徑可以保留 ..
156
+ result.push('..')
157
+ }
158
+ } else {
159
+ result.push(part)
160
+ }
161
+ }
162
+
163
+ let normalized = result.join('/')
164
+ if (isAbs) {
165
+ normalized = `/${normalized}`
166
+ }
167
+ return normalized || (isAbs ? '/' : '.')
168
+ },
169
+ }
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it, jest } from 'bun:test'
2
- import {
3
- DefaultDetectors,
4
- type I18nService,
5
- type LocaleDetector,
6
- localeMiddleware,
7
- } from '../../src/I18nService'
2
+ import { DefaultDetectors, type I18nService, localeMiddleware } from '../../src/I18nService'
8
3
 
9
4
  describe('Locale Detectors', () => {
10
5
  const mockContext = (params: any = {}) => ({
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @file packages/cosmos/tests/unit/edge-kv-loader.test.ts
3
+ * @description EdgeKVLoader 單元測試
4
+ */
5
+
6
+ import { describe, expect, it, mock } from 'bun:test'
7
+ import type { KVStorage } from '../../src/loaders/EdgeKVLoader'
8
+ import { EdgeKVLoader } from '../../src/loaders/EdgeKVLoader'
9
+
10
+ describe('EdgeKVLoader', () => {
11
+ it('should load translations from KV storage', async () => {
12
+ const mockStorage: KVStorage = {
13
+ get: mock(async (key: string) => {
14
+ if (key === 'i18n:en') {
15
+ return JSON.stringify({ hello: 'Hello' })
16
+ }
17
+ return null
18
+ }),
19
+ }
20
+
21
+ const loader = new EdgeKVLoader({
22
+ storage: mockStorage,
23
+ })
24
+
25
+ const result = await loader.load('en')
26
+ expect(result).toEqual({ hello: 'Hello' })
27
+ expect(mockStorage.get).toHaveBeenCalledWith('i18n:en')
28
+ })
29
+
30
+ it('should return null for missing locale', async () => {
31
+ const mockStorage: KVStorage = {
32
+ get: mock(async () => null),
33
+ }
34
+
35
+ const loader = new EdgeKVLoader({
36
+ storage: mockStorage,
37
+ })
38
+
39
+ const result = await loader.load('missing')
40
+ expect(result).toBeNull()
41
+ })
42
+
43
+ it('should handle JSON parse errors', async () => {
44
+ const mockStorage: KVStorage = {
45
+ get: mock(async () => 'invalid json'),
46
+ }
47
+
48
+ const loader = new EdgeKVLoader({
49
+ storage: mockStorage,
50
+ })
51
+
52
+ const result = await loader.load('en')
53
+ expect(result).toBeNull()
54
+ })
55
+
56
+ it('should support custom prefix', async () => {
57
+ const mockStorage: KVStorage = {
58
+ get: mock(async (key: string) => {
59
+ if (key === 'translations:en') {
60
+ return JSON.stringify({ hello: 'Hello' })
61
+ }
62
+ return null
63
+ }),
64
+ }
65
+
66
+ const loader = new EdgeKVLoader({
67
+ storage: mockStorage,
68
+ prefix: 'translations',
69
+ })
70
+
71
+ const result = await loader.load('en')
72
+ expect(result).toEqual({ hello: 'Hello' })
73
+ expect(mockStorage.get).toHaveBeenCalledWith('translations:en')
74
+ })
75
+
76
+ it('should support custom key template', async () => {
77
+ const mockStorage: KVStorage = {
78
+ get: mock(async (key: string) => {
79
+ if (key === 'translations/en.json') {
80
+ return JSON.stringify({ hello: 'Hello' })
81
+ }
82
+ return null
83
+ }),
84
+ }
85
+
86
+ const loader = new EdgeKVLoader({
87
+ storage: mockStorage,
88
+ prefix: 'translations',
89
+ keyTemplate: ':prefix/:locale.json',
90
+ })
91
+
92
+ const result = await loader.load('en')
93
+ expect(result).toEqual({ hello: 'Hello' })
94
+ expect(mockStorage.get).toHaveBeenCalledWith('translations/en.json')
95
+ })
96
+
97
+ it('should support fallback loader', async () => {
98
+ const mockStorage: KVStorage = {
99
+ get: mock(async () => null),
100
+ }
101
+
102
+ const fallbackLoader = {
103
+ name: 'Fallback',
104
+ load: mock(async () => ({ hello: 'Fallback' })),
105
+ }
106
+
107
+ const loader = new EdgeKVLoader({
108
+ storage: mockStorage,
109
+ }).fallback(fallbackLoader)
110
+
111
+ const result = await loader.load('en')
112
+ expect(result).toEqual({ hello: 'Fallback' })
113
+ expect(fallbackLoader.load).toHaveBeenCalledWith('en')
114
+ })
115
+
116
+ it('should support put operation', async () => {
117
+ const mockStorage: KVStorage = {
118
+ get: mock(async () => null),
119
+ put: mock(async () => {}),
120
+ }
121
+
122
+ const loader = new EdgeKVLoader({
123
+ storage: mockStorage,
124
+ })
125
+
126
+ await loader.put('en', { hello: 'Hello' })
127
+
128
+ expect(mockStorage.put).toHaveBeenCalledWith('i18n:en', JSON.stringify({ hello: 'Hello' }))
129
+ })
130
+
131
+ it('should throw error if storage does not support put', async () => {
132
+ const mockStorage: KVStorage = {
133
+ get: mock(async () => null),
134
+ // No put method
135
+ }
136
+
137
+ const loader = new EdgeKVLoader({
138
+ storage: mockStorage,
139
+ })
140
+
141
+ await expect(loader.put('en', { hello: 'Hello' })).rejects.toThrow(
142
+ 'Storage does not support put operation'
143
+ )
144
+ })
145
+
146
+ it('should support delete operation', async () => {
147
+ const mockStorage: KVStorage = {
148
+ get: mock(async () => null),
149
+ delete: mock(async () => {}),
150
+ }
151
+
152
+ const loader = new EdgeKVLoader({
153
+ storage: mockStorage,
154
+ })
155
+
156
+ await loader.delete('en')
157
+
158
+ expect(mockStorage.delete).toHaveBeenCalledWith('i18n:en')
159
+ })
160
+
161
+ it('should throw error if storage does not support delete', async () => {
162
+ const mockStorage: KVStorage = {
163
+ get: mock(async () => null),
164
+ // No delete method
165
+ }
166
+
167
+ const loader = new EdgeKVLoader({
168
+ storage: mockStorage,
169
+ })
170
+
171
+ await expect(loader.delete('en')).rejects.toThrow('Storage does not support delete operation')
172
+ })
173
+
174
+ it('should handle nested translations', async () => {
175
+ const mockStorage: KVStorage = {
176
+ get: mock(async () => {
177
+ return JSON.stringify({
178
+ auth: {
179
+ login: 'Login',
180
+ errors: {
181
+ invalid: 'Invalid credentials',
182
+ },
183
+ },
184
+ })
185
+ }),
186
+ }
187
+
188
+ const loader = new EdgeKVLoader({
189
+ storage: mockStorage,
190
+ })
191
+
192
+ const result = await loader.load('en')
193
+ expect(result).toEqual({
194
+ auth: {
195
+ login: 'Login',
196
+ errors: {
197
+ invalid: 'Invalid credentials',
198
+ },
199
+ },
200
+ })
201
+ })
202
+ })