@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.
- package/MIGRATION.md +331 -0
- package/package.json +20 -5
- package/scripts/check-coverage.ts +64 -0
- package/src/HMRWatcher.ts +305 -0
- package/src/I18nService.ts +149 -9
- package/src/index.edge.ts +35 -0
- package/src/index.node.ts +20 -0
- package/src/index.ts +14 -0
- package/src/loader.ts +31 -14
- package/src/loaders/ChainedLoader.ts +117 -0
- package/src/loaders/CloudflareKVLoader.ts +194 -0
- package/src/loaders/EdgeKVLoader.ts +248 -0
- package/src/loaders/FileSystemLoader.ts +125 -0
- package/src/loaders/MemoryLoader.ts +161 -0
- package/src/loaders/RemoteLoader.ts +235 -0
- package/src/loaders/TranslationLoader.ts +98 -0
- package/src/loaders/VercelKVLoader.ts +192 -0
- package/src/runtime/detector.ts +97 -0
- package/src/runtime/path-utils.ts +169 -0
- package/tests/unit/detector.test.ts +1 -6
- package/tests/unit/edge-kv-loader.test.ts +202 -0
- package/tests/unit/hmr.test.ts +255 -0
- package/tests/unit/loader.test.ts +57 -29
- package/tests/unit/loaders.test.ts +332 -0
- package/tests/unit/memory-loader.test.ts +130 -0
- package/tests/unit/path-utils.test.ts +135 -0
- package/tests/unit/runtime-detector.test.ts +86 -0
|
@@ -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
|
+
})
|