@skopon-cool/form-sdk 0.6.0 → 0.6.1
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/README.md +14 -2
- package/dist/client/authStorage.d.ts +9 -0
- package/dist/client/authStorage.d.ts.map +1 -1
- package/dist/client/uploadClient.d.ts +1 -1
- package/dist/client/uploadClient.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1105 -1068
- package/package.json +1 -1
- package/src/client/authStorage.test.ts +67 -1
- package/src/client/authStorage.ts +67 -0
- package/src/client/uploadClient.test.ts +41 -2
- package/src/client/uploadClient.ts +7 -3
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @vitest-environment happy-dom
|
|
3
3
|
*/
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
|
|
7
7
|
DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
|
|
8
|
+
DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD,
|
|
8
9
|
DEFAULT_ANSWER_AGENT_CALLBACK_URL,
|
|
9
10
|
DEFAULT_AUTH_STORAGE_KEY,
|
|
10
11
|
resolveAnswerAgentCallbackUrl,
|
|
11
12
|
resolveRealtimeWsUrl,
|
|
13
|
+
resolveSkoponApiBaseUrl,
|
|
12
14
|
resolveSkoponAuthHeaders,
|
|
13
15
|
resolveSubmitAuthHeaders,
|
|
14
16
|
} from './authStorage'
|
|
@@ -17,6 +19,7 @@ describe('resolveSkoponAuthHeaders', () => {
|
|
|
17
19
|
afterEach(() => {
|
|
18
20
|
sessionStorage.clear()
|
|
19
21
|
localStorage.clear()
|
|
22
|
+
vi.unstubAllGlobals()
|
|
20
23
|
})
|
|
21
24
|
|
|
22
25
|
it('reads Bearer token from sessionStorage auth_user by default', () => {
|
|
@@ -66,6 +69,69 @@ describe('resolveSkoponAuthHeaders', () => {
|
|
|
66
69
|
})
|
|
67
70
|
})
|
|
68
71
|
|
|
72
|
+
describe('resolveSkoponApiBaseUrl', () => {
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.clear()
|
|
75
|
+
if (typeof localStorage !== 'undefined') localStorage.clear()
|
|
76
|
+
vi.unstubAllGlobals()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reads and normalizes skopon_api_base_url from auth_user', () => {
|
|
80
|
+
sessionStorage.setItem(
|
|
81
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
[DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]:
|
|
84
|
+
' https://admin-test.hibot.lol/api/skopon/api/v1/ ',
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
expect(resolveSkoponApiBaseUrl()).toBe(
|
|
88
|
+
'https://admin-test.hibot.lol/api/skopon/api/v1',
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('falls back to the current origin when session or field is missing', () => {
|
|
93
|
+
expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
|
|
94
|
+
sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
|
|
95
|
+
expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('falls back to a relative API path outside the browser', () => {
|
|
99
|
+
vi.stubGlobal('window', undefined)
|
|
100
|
+
expect(resolveSkoponApiBaseUrl()).toBe('/api/v1')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('falls back when browser storage is inaccessible', () => {
|
|
104
|
+
const origin = window.location.origin
|
|
105
|
+
vi.stubGlobal('window', {
|
|
106
|
+
location: { origin },
|
|
107
|
+
sessionStorage: {
|
|
108
|
+
getItem() {
|
|
109
|
+
throw new Error('blocked')
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
expect(resolveSkoponApiBaseUrl()).toBe(`${origin}/api/v1`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it.each([
|
|
117
|
+
'/api/v1',
|
|
118
|
+
'ftp://example.com/api/v1',
|
|
119
|
+
'https://user:pass@example.com/api/v1',
|
|
120
|
+
'https://example.com/api/v1?tenant=1',
|
|
121
|
+
'https://example.com/api/v1#upload',
|
|
122
|
+
])('rejects invalid configured base URL: %s', (configured) => {
|
|
123
|
+
sessionStorage.setItem(
|
|
124
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
[DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]: configured,
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
expect(() => resolveSkoponApiBaseUrl()).toThrow(
|
|
130
|
+
'skopon_api_base_url 格式无效,无法上传文件',
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
69
135
|
describe('resolveSubmitAuthHeaders', () => {
|
|
70
136
|
afterEach(() => {
|
|
71
137
|
sessionStorage.clear()
|
|
@@ -11,8 +11,10 @@ export const DEFAULT_AUTH_STORAGE_KEY = 'auth_user'
|
|
|
11
11
|
export const DEFAULT_AUTH_TOKEN_FIELD = 'token'
|
|
12
12
|
export const DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD = 'answer_agent_callback_url'
|
|
13
13
|
export const DEFAULT_AUTH_REALTIME_WS_URL_FIELD = 'realtime_ws_url'
|
|
14
|
+
export const DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD = 'skopon_api_base_url'
|
|
14
15
|
/** 与 skopon-admin 登录写入的 mock answer-agent 路径一致 */
|
|
15
16
|
export const DEFAULT_ANSWER_AGENT_CALLBACK_URL = '/api/v1/mock/ask_user_answer_mock'
|
|
17
|
+
export const DEFAULT_SKOPON_API_BASE_PATH = '/api/v1'
|
|
16
18
|
|
|
17
19
|
function readStorage(
|
|
18
20
|
kind: 'sessionStorage' | 'localStorage',
|
|
@@ -35,6 +37,71 @@ function readSessionJson(raw: string): Record<string, unknown> | null {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function defaultSkoponApiBaseUrl(): string {
|
|
41
|
+
if (typeof window === 'undefined') return DEFAULT_SKOPON_API_BASE_PATH
|
|
42
|
+
try {
|
|
43
|
+
const origin = window.location.origin
|
|
44
|
+
const parsed = new URL(origin)
|
|
45
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
46
|
+
return DEFAULT_SKOPON_API_BASE_PATH
|
|
47
|
+
}
|
|
48
|
+
return `${parsed.origin}${DEFAULT_SKOPON_API_BASE_PATH}`
|
|
49
|
+
} catch {
|
|
50
|
+
return DEFAULT_SKOPON_API_BASE_PATH
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 从宿主登录缓存读取 Skopon API 根地址。
|
|
56
|
+
* 缺失时回退当前 Origin 的 /api/v1;非空配置必须是无凭据、Query、Hash 的绝对 HTTP(S) URL。
|
|
57
|
+
*/
|
|
58
|
+
export function resolveSkoponApiBaseUrl(
|
|
59
|
+
options: SkoponAuthStorageOptions & {
|
|
60
|
+
skoponApiBaseUrlField?: string
|
|
61
|
+
} = {},
|
|
62
|
+
): string {
|
|
63
|
+
const storageKind = options.storage ?? 'sessionStorage'
|
|
64
|
+
const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
|
|
65
|
+
const field =
|
|
66
|
+
options.skoponApiBaseUrlField ?? DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD
|
|
67
|
+
|
|
68
|
+
const storage = readStorage(storageKind)
|
|
69
|
+
let raw: string | null = null
|
|
70
|
+
try {
|
|
71
|
+
raw = storage?.getItem(storageKey) ?? null
|
|
72
|
+
} catch {
|
|
73
|
+
return defaultSkoponApiBaseUrl()
|
|
74
|
+
}
|
|
75
|
+
const parsedSession = raw ? readSessionJson(raw) : null
|
|
76
|
+
const configured = parsedSession?.[field]
|
|
77
|
+
if (configured === undefined || configured === null) {
|
|
78
|
+
return defaultSkoponApiBaseUrl()
|
|
79
|
+
}
|
|
80
|
+
if (typeof configured !== 'string') {
|
|
81
|
+
throw new Error('skopon_api_base_url 格式无效,无法上传文件')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const trimmed = configured.trim()
|
|
85
|
+
if (!trimmed) return defaultSkoponApiBaseUrl()
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(trimmed)
|
|
89
|
+
if (
|
|
90
|
+
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
|
91
|
+
parsed.username ||
|
|
92
|
+
parsed.password ||
|
|
93
|
+
parsed.search ||
|
|
94
|
+
parsed.hash
|
|
95
|
+
) {
|
|
96
|
+
throw new Error('invalid Skopon API base URL')
|
|
97
|
+
}
|
|
98
|
+
const pathname = parsed.pathname.replace(/\/+$/, '')
|
|
99
|
+
return `${parsed.origin}${pathname}`
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error('skopon_api_base_url 格式无效,无法上传文件')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
38
105
|
function readTokenFromSessionJson(
|
|
39
106
|
raw: string,
|
|
40
107
|
tokenField: string,
|
|
@@ -23,7 +23,13 @@ describe('uploadFileToCdn', () => {
|
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
it('uploads multipart data to the fixed endpoint with session Bearer auth', async () => {
|
|
26
|
-
sessionStorage.setItem(
|
|
26
|
+
sessionStorage.setItem(
|
|
27
|
+
'auth_user',
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
token: 'token-1',
|
|
30
|
+
skopon_api_base_url: 'https://admin-test.hibot.lol/api/skopon/api/v1/',
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
27
33
|
const fetchMock = vi.fn(async () =>
|
|
28
34
|
response({
|
|
29
35
|
success: true,
|
|
@@ -39,7 +45,9 @@ describe('uploadFileToCdn', () => {
|
|
|
39
45
|
})
|
|
40
46
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
41
47
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
|
42
|
-
expect(url).toBe(
|
|
48
|
+
expect(url).toBe(
|
|
49
|
+
`https://admin-test.hibot.lol/api/skopon/api/v1${CDN_UPLOAD_PATH}`,
|
|
50
|
+
)
|
|
43
51
|
expect(init.method).toBe('POST')
|
|
44
52
|
expect(init.headers).toEqual({ Authorization: 'Bearer token-1' })
|
|
45
53
|
expect((init.headers as Record<string, string>)['Content-Type']).toBeUndefined()
|
|
@@ -48,6 +56,37 @@ describe('uploadFileToCdn', () => {
|
|
|
48
56
|
expect((init.body as FormData).get('purpose')).toBeNull()
|
|
49
57
|
})
|
|
50
58
|
|
|
59
|
+
it('falls back to the current origin API base when session config is missing', async () => {
|
|
60
|
+
const fetchMock = vi.fn(async () =>
|
|
61
|
+
response({ success: true, data: { url: 'https://cdn.example.com/a.txt' } }),
|
|
62
|
+
)
|
|
63
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
64
|
+
|
|
65
|
+
await uploadFileToCdn(new File(['x'], 'x.txt'))
|
|
66
|
+
|
|
67
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
68
|
+
`${window.location.origin}/api/v1${CDN_UPLOAD_PATH}`,
|
|
69
|
+
expect.objectContaining({ method: 'POST' }),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects an invalid configured base URL without sending a request', async () => {
|
|
74
|
+
sessionStorage.setItem(
|
|
75
|
+
'auth_user',
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
token: 'token-1',
|
|
78
|
+
skopon_api_base_url: '/api/v1',
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
const fetchMock = vi.fn()
|
|
82
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
83
|
+
|
|
84
|
+
await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow(
|
|
85
|
+
'skopon_api_base_url 格式无效,无法上传文件',
|
|
86
|
+
)
|
|
87
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
88
|
+
})
|
|
89
|
+
|
|
51
90
|
it.each([
|
|
52
91
|
[{ success: false, message: '七牛上传失败', data: {} }, '七牛上传失败'],
|
|
53
92
|
[{ success: true, data: { url: ' ' } }, '响应缺少文件地址'],
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { parseEnvelope } from './apiEnvelope'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolveSkoponApiBaseUrl,
|
|
4
|
+
resolveSkoponAuthHeaders,
|
|
5
|
+
} from './authStorage'
|
|
3
6
|
|
|
4
|
-
export const CDN_UPLOAD_PATH = '/
|
|
7
|
+
export const CDN_UPLOAD_PATH = '/univ/upload/cdn'
|
|
5
8
|
export const CDN_UPLOAD_TIMEOUT_MS = 120_000
|
|
6
9
|
|
|
7
10
|
export interface CdnUploadResult {
|
|
@@ -27,6 +30,7 @@ export async function uploadFileToCdn(
|
|
|
27
30
|
file: File,
|
|
28
31
|
options: { signal?: AbortSignal } = {},
|
|
29
32
|
): Promise<CdnUploadResult> {
|
|
33
|
+
const uploadUrl = `${resolveSkoponApiBaseUrl()}${CDN_UPLOAD_PATH}`
|
|
30
34
|
const controller = new AbortController()
|
|
31
35
|
let timedOut = false
|
|
32
36
|
|
|
@@ -45,7 +49,7 @@ export async function uploadFileToCdn(
|
|
|
45
49
|
try {
|
|
46
50
|
const formData = new FormData()
|
|
47
51
|
formData.append('file', file)
|
|
48
|
-
const response = await fetch(
|
|
52
|
+
const response = await fetch(uploadUrl, {
|
|
49
53
|
method: 'POST',
|
|
50
54
|
headers: resolveSkoponAuthHeaders(),
|
|
51
55
|
body: formData,
|
package/src/index.ts
CHANGED
|
@@ -106,6 +106,7 @@ export {
|
|
|
106
106
|
export {
|
|
107
107
|
resolveAnswerAgentCallbackUrl,
|
|
108
108
|
resolveRealtimeWsUrl,
|
|
109
|
+
resolveSkoponApiBaseUrl,
|
|
109
110
|
resolveSkoponAuthHeaders,
|
|
110
111
|
resolveSubmitAuthHeaders,
|
|
111
112
|
resolveClientHeaders,
|
|
@@ -113,7 +114,9 @@ export {
|
|
|
113
114
|
DEFAULT_AUTH_TOKEN_FIELD,
|
|
114
115
|
DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
|
|
115
116
|
DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
|
|
117
|
+
DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD,
|
|
116
118
|
DEFAULT_ANSWER_AGENT_CALLBACK_URL,
|
|
119
|
+
DEFAULT_SKOPON_API_BASE_PATH,
|
|
117
120
|
type SkoponAuthStorageOptions,
|
|
118
121
|
type ClientHeadersOptions,
|
|
119
122
|
} from './client/authStorage'
|