@deplois/session 0.0.0-stage → 0.0.2
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 +114 -2
- package/module.mjs +10 -0
- package/package.json +37 -4
- package/runtime/plugin.ts +55 -0
- package/runtime/session.ts +389 -0
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @deplois/session
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Deplois API 규격을 사용하는 Nuxt 4 세션 플러그인입니다. 버전은 `major.minor.fetch` 순서이며 시작 버전은 `1.0.0`입니다. 현재 백엔드 버전 비교는 하지 않습니다.
|
|
4
|
+
|
|
5
|
+
## 설치
|
|
6
|
+
|
|
7
|
+
배포 전에는 로컬 패키지 경로로 설치합니다.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install ../session
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
npm 배포 후에는 `npm install @deplois/session`으로 설치합니다.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// nuxt.config.ts
|
|
17
|
+
export default defineNuxtConfig({
|
|
18
|
+
modules: ['@deplois/session']
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```dotenv
|
|
23
|
+
API_ENDPOINT=http://localhost:4002
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`API_ENDPOINT`는 Nuxt 개발/빌드 시 읽습니다. 빌드된 서버 실행 시 변경하려면 `NUXT_PUBLIC_API_ENDPOINT`를 사용합니다. 공개 API 주소이므로 비밀키를 넣지 않습니다.
|
|
27
|
+
|
|
28
|
+
기존 `$session`, `$api` 제공 플러그인과 중복 등록하지 않습니다. 디자인 패키지 `@deplois/template`와는 독립적입니다.
|
|
29
|
+
|
|
30
|
+
## 사용
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const { $api, $session } = useNuxtApp()
|
|
34
|
+
|
|
35
|
+
onMounted(async () => {
|
|
36
|
+
const result = await $api.get('/v1/user/me')
|
|
37
|
+
if (result.success == false) {
|
|
38
|
+
console.error(result.error)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
console.log(result.data)
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`$api.get/post/put/patch/delete`는 Axios와 같은 인자를 받습니다. 첫 요청에서 세션을 자동 생성하거나 복원합니다. 응답의 `data`는 서버의 `response.data.data`, 오류는 서버의 `response.data.error`입니다. 서버의 오류 객체와 추가 필드는 그대로 유지합니다. `status`, `raw`도 반환합니다.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const session = $session().data
|
|
49
|
+
await session.initialize() // 명시적으로 준비할 때 (실패 시 throw)
|
|
50
|
+
await session.initialize(true) // 서버에 다시 확인
|
|
51
|
+
session.state.ready
|
|
52
|
+
session.state.record?.id
|
|
53
|
+
session.state.error
|
|
54
|
+
await session.revoke() // DELETE /session 후 로컬 상태 삭제
|
|
55
|
+
session.clear() // 로컬 상태만 삭제, 서버 세션 철회 아님
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
세션 요청은 브라우저에서 실행합니다. SSR에서 호출하면 `CLIENT_SESSION_REQUIRED` 오류 결과를 반환합니다. 세션 토큰을 SSR HTML/payload에 직렬화하지 않습니다. 초기화는 지연 실행되며 페이지 이동은 앱에서 관리합니다.
|
|
59
|
+
|
|
60
|
+
## API 규격
|
|
61
|
+
|
|
62
|
+
- `PUT /session`: `{ success: true, data: { id, token, expires_in }, error: null }`
|
|
63
|
+
- `GET /session`: `{ success: true, data: { expires_in }, error: null }`
|
|
64
|
+
- `DELETE /session`: 세션 철회
|
|
65
|
+
- 인증 헤더: `Authorization: Bearer <session token>`
|
|
66
|
+
- 실패: `{ success: false, data: null, error: { code, message } }`
|
|
67
|
+
- `expires_in` 단위는 초입니다. 세션 사용 시 연장은 서버가 담당합니다.
|
|
68
|
+
|
|
69
|
+
저장은 API 주소별 localStorage 키로 분리합니다. 같은 탭의 초기화 요청은 합치며, 지원 브라우저에서는 Web Locks로 탭 간 생성도 직렬화합니다. 다른 탭의 저장 변경을 감지합니다. 저장소 접근이 불가능하면 현재 탭 메모리만 사용합니다.
|
|
70
|
+
|
|
71
|
+
저장된 세션이 `EXPIRED_SESSION`, `WRONG_SESSION`, `REQUIRED_SESSION`이면 초기화 시 새 세션을 생성합니다. 일반 API 요청 중 세션 오류가 발생하면 상태를 초기화하고 오류를 반환합니다. 결제 같은 요청이 중복 실행되지 않도록 실패한 요청을 자동 재전송하지 않습니다. 다음 요청부터 새 세션을 생성합니다.
|
|
72
|
+
|
|
73
|
+
## 검증
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm test
|
|
77
|
+
npm pack --dry-run
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Access token
|
|
81
|
+
|
|
82
|
+
세션 인증은 `$api.get/post/put/patch/delete`, access token 인증은 `$api.access.get/post/put/patch/delete`로 구분합니다. 인자와 응답 형식은 같습니다.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const { $api } = useNuxtApp()
|
|
86
|
+
|
|
87
|
+
// OAuth callback 등에서 전달받은 토큰 저장
|
|
88
|
+
$api.access.setToken(accessToken)
|
|
89
|
+
$api.access.hasToken()
|
|
90
|
+
|
|
91
|
+
const result = await $api.access.get('/v1/user/me')
|
|
92
|
+
|
|
93
|
+
// 로그아웃할 때 로컬 토큰 삭제
|
|
94
|
+
$api.access.clearToken()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Access token은 API 주소별 `sessionStorage`에 보관하여 같은 탭에서 새로고침해도 복원됩니다. 저장소 접근이 불가능하면 메모리만 사용합니다. `Bearer ` 접두어를 포함한 토큰도 받을 수 있습니다.
|
|
98
|
+
|
|
99
|
+
Access 요청은 세션 생성 없이 `Authorization: Bearer <access token>`을 보냅니다. 토큰이 없으면 `REQUIRED_ACCESS_TOKEN`, SSR에서 호출하면 `CLIENT_ACCESS_REQUIRED` 오류를 반환합니다. 서버가 401을 반환하면 해당 요청에 사용한 토큰이 현재 토큰인 경우에만 삭제합니다. 토큰 발급·refresh·서버 철회는 앱의 OAuth 흐름에서 처리합니다.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
## 로컬 배포 명령
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
npm run publish -- 1.2.3 "커밋 메시지" --deploy
|
|
106
|
+
npm run publish -- major "커밋 메시지" --deploy
|
|
107
|
+
npm run publish -- minor "커밋 메시지" --deploy
|
|
108
|
+
npm run publish -- fetch "커밋 메시지" --deploy
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
현재 버전이 `1.2.3`일 때 `major`는 `2.0.0`, `minor`는 `1.3.0`, `fetch`는 `1.2.4`가 됩니다. 상위 버전을 올리면 하위 버전은 0으로 초기화합니다.
|
|
112
|
+
|
|
113
|
+
`--deploy`를 빼면 버전 변경·커밋·Git push까지만 진행합니다. `npm run deploy`는 현재 버전을 npm에만 배포합니다.
|
|
114
|
+
|
|
115
|
+
`scripts/publish.mjs`는 로컬 전용으로 Git 추적과 npm 패키지에서 제외합니다. 새로 clone한 환경에서 배포 명령을 쓰려면 이 파일을 별도로 복사해야 합니다.
|
package/module.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addPlugin } from '@nuxt/kit'
|
|
2
|
+
|
|
3
|
+
export default defineNuxtModule({
|
|
4
|
+
meta: { name: '@deplois/session', configKey: 'session' },
|
|
5
|
+
setup(options, nuxt) {
|
|
6
|
+
const resolve = createResolver(import.meta.url)
|
|
7
|
+
nuxt.options.runtimeConfig.public.apiEndpoint ||= process.env.API_ENDPOINT || ''
|
|
8
|
+
addPlugin(resolve.resolve('./runtime/plugin'))
|
|
9
|
+
}
|
|
10
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deplois/session",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./module.mjs",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./module.mjs",
|
|
8
|
+
"./runtime": "./runtime/session.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"module.mjs",
|
|
12
|
+
"runtime",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.mjs",
|
|
17
|
+
"pack": "npm pack",
|
|
18
|
+
"check": "npm test",
|
|
19
|
+
"publish": "node scripts/publish.mjs",
|
|
20
|
+
"deploy": "node scripts/publish.mjs"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@nuxt/kit": "^4.1.2",
|
|
24
|
+
"axios": "^1.13.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"nuxt": "^4.1.2",
|
|
28
|
+
"vue": "^3.5.21"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "^5.6.3",
|
|
32
|
+
"vue": "^3.5.21"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"license": "UNLICENSED"
|
|
39
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
|
|
2
|
+
import { APIClientError, AccessAPI, Session } from './session'
|
|
3
|
+
|
|
4
|
+
export default defineNuxtPlugin({
|
|
5
|
+
name: 'deplois-session',
|
|
6
|
+
setup() {
|
|
7
|
+
const config = useRuntimeConfig()
|
|
8
|
+
const baseURL = String(config.public.apiEndpoint || '').replace(/\/+$/, '')
|
|
9
|
+
if (!/^https?:\/\//.test(baseURL)) {
|
|
10
|
+
throw new APIClientError('INVALID_API_ENDPOINT', 'Set API_ENDPOINT to an absolute HTTP or HTTPS URL.')
|
|
11
|
+
}
|
|
12
|
+
const key = `deplois.session:${baseURL}`
|
|
13
|
+
const access = new AccessAPI({
|
|
14
|
+
baseURL: baseURL,
|
|
15
|
+
key: `deplois.access:${baseURL}`,
|
|
16
|
+
storage: {
|
|
17
|
+
getItem: name => import.meta.client ? window.sessionStorage.getItem(name) : null,
|
|
18
|
+
setItem: (name, value) => { if (import.meta.client) window.sessionStorage.setItem(name, value) },
|
|
19
|
+
removeItem: name => { if (import.meta.client) window.sessionStorage.removeItem(name) }
|
|
20
|
+
},
|
|
21
|
+
beforeRequest: () => {
|
|
22
|
+
if (import.meta.server) throw new APIClientError('CLIENT_ACCESS_REQUIRED', 'Access token requests must run in the browser.')
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
const session = new Session({
|
|
26
|
+
baseURL: baseURL,
|
|
27
|
+
key: key,
|
|
28
|
+
storage: {
|
|
29
|
+
getItem: name => import.meta.client ? window.localStorage.getItem(name) : null,
|
|
30
|
+
setItem: (name, value) => { if (import.meta.client) window.localStorage.setItem(name, value) },
|
|
31
|
+
removeItem: name => { if (import.meta.client) window.localStorage.removeItem(name) }
|
|
32
|
+
},
|
|
33
|
+
lock: async <T>(work: () => Promise<T>): Promise<T> => {
|
|
34
|
+
if (import.meta.server) throw new APIClientError('CLIENT_SESSION_REQUIRED', 'Session requests must run in the browser.')
|
|
35
|
+
if (navigator.locks) return await navigator.locks.request(key, work)
|
|
36
|
+
return await work()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (import.meta.client) {
|
|
41
|
+
const listener = (event: StorageEvent) => {
|
|
42
|
+
if (event.key === key || event.key === null) session.sync()
|
|
43
|
+
}
|
|
44
|
+
window.addEventListener('storage', listener)
|
|
45
|
+
if (import.meta.hot) import.meta.hot.dispose(() => window.removeEventListener('storage', listener))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
provide: {
|
|
50
|
+
session: () => ({ success: true, data: session }),
|
|
51
|
+
api: { ...session.api, access: access.api }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
})
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import axios, { type AxiosRequestConfig, type AxiosAdapter, type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
|
2
|
+
import { reactive } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface SessionRecord {
|
|
5
|
+
id: string
|
|
6
|
+
token: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SessionState {
|
|
10
|
+
record: SessionRecord | null
|
|
11
|
+
ready: boolean
|
|
12
|
+
expiresAt: number | null
|
|
13
|
+
error: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiEnvelope<T = unknown> {
|
|
17
|
+
success: boolean
|
|
18
|
+
data: T
|
|
19
|
+
error: { code: string, message: string } | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type APIResult<T> = {
|
|
23
|
+
success: true
|
|
24
|
+
data: T
|
|
25
|
+
status: number
|
|
26
|
+
raw: AxiosResponse<ApiEnvelope<T>>
|
|
27
|
+
} | {
|
|
28
|
+
success: false
|
|
29
|
+
error: string | NonNullable<ApiEnvelope<unknown>['error']>
|
|
30
|
+
status: number
|
|
31
|
+
raw: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class APIClientError extends Error {
|
|
35
|
+
public readonly response: { status: number, data: { error: string } }
|
|
36
|
+
|
|
37
|
+
constructor(public code: string, message: string, public status = 0, public raw?: unknown) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = 'APIClientError'
|
|
40
|
+
this.response = { status, data: { error: message } }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class AccessAPI {
|
|
45
|
+
private axios: AxiosInstance
|
|
46
|
+
private token: string | null = null
|
|
47
|
+
|
|
48
|
+
public api = {
|
|
49
|
+
get: <T = any>(url: string, config?: AxiosRequestConfig) => this.response(this.axios.get<ApiEnvelope<T>>(url, config)),
|
|
50
|
+
post: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.post<ApiEnvelope<T>>(url, data, config)),
|
|
51
|
+
put: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.put<ApiEnvelope<T>>(url, data, config)),
|
|
52
|
+
patch: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.patch<ApiEnvelope<T>>(url, data, config)),
|
|
53
|
+
delete: <T = any>(url: string, config?: AxiosRequestConfig) => this.response(this.axios.delete<ApiEnvelope<T>>(url, config)),
|
|
54
|
+
setToken: (token: string) => this.setToken(token),
|
|
55
|
+
clearToken: () => this.clearToken(),
|
|
56
|
+
hasToken: () => this.token != null || this.read() != null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
constructor(private options: Pick<SessionOptions, 'baseURL' | 'adapter'> & {
|
|
60
|
+
key: string
|
|
61
|
+
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
62
|
+
beforeRequest?: () => void
|
|
63
|
+
}) {
|
|
64
|
+
this.axios = axios.create({ baseURL: options.baseURL, timeout: 15000, adapter: options.adapter })
|
|
65
|
+
this.token = this.read()
|
|
66
|
+
this.axios.interceptors.request.use(config => {
|
|
67
|
+
this.options.beforeRequest?.()
|
|
68
|
+
const base = new URL(this.options.baseURL)
|
|
69
|
+
const target = new URL(this.axios.getUri(config), `${this.options.baseURL}/`)
|
|
70
|
+
const path = base.pathname.replace(/\/+$/, '')
|
|
71
|
+
if (target.origin !== base.origin || target.username || target.password
|
|
72
|
+
|| (path && target.pathname !== path && !target.pathname.startsWith(`${path}/`))) {
|
|
73
|
+
throw new APIClientError('INVALID_URL', 'Invalid API path.')
|
|
74
|
+
}
|
|
75
|
+
const token = this.token ?? this.read()
|
|
76
|
+
if (!token) throw new APIClientError('REQUIRED_ACCESS_TOKEN', 'Required access token.', 401)
|
|
77
|
+
this.token = token
|
|
78
|
+
config.headers.set('Authorization', `Bearer ${token}`, true)
|
|
79
|
+
return config
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private read(): string | null {
|
|
84
|
+
try {
|
|
85
|
+
const token = this.options.storage.getItem(this.options.key)
|
|
86
|
+
return typeof token === 'string' && token.trim() ? token.replace(/^Bearer\s+/i, '').trim() : null
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private setToken(token: string): void {
|
|
93
|
+
const value = token.replace(/^Bearer\s+/i, '').trim()
|
|
94
|
+
this.token = value || null
|
|
95
|
+
try {
|
|
96
|
+
if (this.token) this.options.storage.setItem(this.options.key, this.token)
|
|
97
|
+
else this.options.storage.removeItem(this.options.key)
|
|
98
|
+
} catch { }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private clearToken(): void {
|
|
102
|
+
this.token = null
|
|
103
|
+
try { this.options.storage.removeItem(this.options.key) } catch { }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async response<T>(request: Promise<AxiosResponse<ApiEnvelope<T>>>): Promise<APIResult<T>> {
|
|
107
|
+
try {
|
|
108
|
+
const response = await request
|
|
109
|
+
if (response.data?.success !== true) {
|
|
110
|
+
throw new APIClientError(
|
|
111
|
+
response.data?.error?.code ?? 'INVALID_RESPONSE',
|
|
112
|
+
response.data?.error?.message ?? 'Invalid server response.',
|
|
113
|
+
response.status,
|
|
114
|
+
response
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return { success: true, data: response.data.data, status: response.status, raw: response }
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const result = this.error(error)
|
|
120
|
+
const raw = result.raw ?? error
|
|
121
|
+
const config = axios.isAxiosError(raw) ? raw.config
|
|
122
|
+
: raw && typeof raw === 'object' && 'config' in raw ? (raw as AxiosResponse).config : null
|
|
123
|
+
if (result.status === 401 && this.token && config?.headers.get('Authorization') === `Bearer ${this.token}`) {
|
|
124
|
+
this.clearToken()
|
|
125
|
+
}
|
|
126
|
+
const body = axios.isAxiosError(raw) ? raw.response?.data
|
|
127
|
+
: raw && typeof raw === 'object' && 'data' in raw ? (raw as AxiosResponse<ApiEnvelope<unknown>>).data : null
|
|
128
|
+
return { success: false, error: body?.error ?? { code: result.code, message: result.message }, status: result.status, raw }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private error(error: unknown): APIClientError {
|
|
133
|
+
if (error instanceof APIClientError) return error
|
|
134
|
+
if (axios.isAxiosError(error)) {
|
|
135
|
+
const detail = error.response?.data?.error
|
|
136
|
+
return new APIClientError(
|
|
137
|
+
detail?.code ?? (error.response ? 'HTTP_ERROR' : 'NETWORK_ERROR'),
|
|
138
|
+
detail?.message ?? error.response?.data?.message ?? (error.response ? 'The request failed.' : 'Unable to connect to the server.'),
|
|
139
|
+
error.response?.status ?? 0,
|
|
140
|
+
error
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return new APIClientError('REQUEST_FAILED', error instanceof Error ? error.message : 'The request failed.')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface SessionOptions {
|
|
148
|
+
baseURL: string
|
|
149
|
+
key: string
|
|
150
|
+
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
151
|
+
state?: SessionState
|
|
152
|
+
adapter?: AxiosAdapter
|
|
153
|
+
lock?: <T>(work: () => Promise<T>) => Promise<T>
|
|
154
|
+
onInvalidated?: () => void
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sessionErrors = new Set(['EXPIRED_SESSION', 'WRONG_SESSION', 'REQUIRED_SESSION'])
|
|
158
|
+
|
|
159
|
+
export class Session {
|
|
160
|
+
public state: SessionState
|
|
161
|
+
public axios: AxiosInstance
|
|
162
|
+
|
|
163
|
+
public api = {
|
|
164
|
+
get: <T = any>(url: string, config?: AxiosRequestConfig) => this.response(this.axios.get<ApiEnvelope<T>>(url, config)),
|
|
165
|
+
post: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.post<ApiEnvelope<T>>(url, data, config)),
|
|
166
|
+
put: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.put<ApiEnvelope<T>>(url, data, config)),
|
|
167
|
+
patch: <T = any>(url: string, data?: unknown, config?: AxiosRequestConfig) => this.response(this.axios.patch<ApiEnvelope<T>>(url, data, config)),
|
|
168
|
+
delete: <T = any>(url: string, config?: AxiosRequestConfig) => this.response(this.axios.delete<ApiEnvelope<T>>(url, config))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async response<T>(request: Promise<AxiosResponse<ApiEnvelope<T>>>): Promise<APIResult<T>> {
|
|
172
|
+
try {
|
|
173
|
+
const response = await request
|
|
174
|
+
return { success: true, data: response.data.data, status: response.status, raw: response }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const result = this.error(error)
|
|
177
|
+
const raw = result.raw ?? error
|
|
178
|
+
const body = axios.isAxiosError(raw) ? raw.response?.data
|
|
179
|
+
: raw && typeof raw === 'object' && 'data' in raw ? (raw as AxiosResponse<ApiEnvelope<unknown>>).data : null
|
|
180
|
+
return { success: false, error: body?.error ?? { code: result.code, message: result.message }, status: result.status, raw }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private message(value: unknown, fallback: string): string {
|
|
185
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
const messages = value.filter((item): item is string => typeof item === 'string' && !!item.trim())
|
|
188
|
+
if (messages.length) return messages.join('\n')
|
|
189
|
+
}
|
|
190
|
+
return fallback
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private transport: AxiosInstance
|
|
194
|
+
private pending: Promise<void> | null = null
|
|
195
|
+
private revision: number = 0
|
|
196
|
+
private duration: number = 86400
|
|
197
|
+
|
|
198
|
+
constructor(private options: SessionOptions) {
|
|
199
|
+
this.state = options.state ?? reactive({ record: null, ready: false, expiresAt: null, error: null })
|
|
200
|
+
const config = { baseURL: options.baseURL, timeout: 15000, adapter: options.adapter }
|
|
201
|
+
this.transport = axios.create(config)
|
|
202
|
+
this.axios = axios.create(config)
|
|
203
|
+
|
|
204
|
+
this.axios.interceptors.request.use(async config => {
|
|
205
|
+
this.validateURL(config)
|
|
206
|
+
await this.initialize()
|
|
207
|
+
const token = this.state.record?.token
|
|
208
|
+
if (!token) throw new APIClientError('SESSION_CHANGED', 'The session has changed. Please try again.')
|
|
209
|
+
config.headers.set('Authorization', `Bearer ${token}`, true)
|
|
210
|
+
return config
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
this.axios.interceptors.response.use(response => {
|
|
214
|
+
try {
|
|
215
|
+
this.check(response)
|
|
216
|
+
if (response.config.headers.get('Authorization') !== `Bearer ${this.state.record?.token}`) {
|
|
217
|
+
throw new APIClientError('SESSION_CHANGED', 'The session has changed. Please try again.')
|
|
218
|
+
}
|
|
219
|
+
this.state.expiresAt = Date.now() + this.duration * 1000
|
|
220
|
+
return response
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw this.error(error, response.config)
|
|
223
|
+
}
|
|
224
|
+
}, error => {
|
|
225
|
+
throw this.error(error, error?.config)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public get isInitialized(): boolean {
|
|
230
|
+
return this.state.ready
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private read(): SessionRecord | null {
|
|
234
|
+
try {
|
|
235
|
+
const record = JSON.parse(this.options.storage.getItem(this.options.key) ?? 'null')
|
|
236
|
+
if (typeof record?.id !== 'string' || !record.id) return null
|
|
237
|
+
if (typeof record?.token !== 'string' || !record.token) return null
|
|
238
|
+
return { id: record.id, token: record.token }
|
|
239
|
+
} catch {
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private write(record: SessionRecord | null): void {
|
|
245
|
+
try {
|
|
246
|
+
if (record) this.options.storage.setItem(this.options.key, JSON.stringify(record))
|
|
247
|
+
else this.options.storage.removeItem(this.options.key)
|
|
248
|
+
} catch {
|
|
249
|
+
// Keep the in-memory session when browser storage is unavailable.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private error(error: unknown, config?: InternalAxiosRequestConfig): APIClientError {
|
|
254
|
+
let result: APIClientError
|
|
255
|
+
if (error instanceof APIClientError) result = error
|
|
256
|
+
else if (axios.isAxiosError(error)) {
|
|
257
|
+
const detail = error.response?.data?.error
|
|
258
|
+
result = new APIClientError(
|
|
259
|
+
detail?.code ?? (error.response ? 'HTTP_ERROR' : 'NETWORK_ERROR'),
|
|
260
|
+
this.message(typeof detail === 'string' ? detail : detail?.message ?? error.response?.data?.message,
|
|
261
|
+
error.response ? 'The request failed.' : 'Unable to connect to the server. Please try again later.'),
|
|
262
|
+
error.response?.status ?? 0,
|
|
263
|
+
error
|
|
264
|
+
)
|
|
265
|
+
} else {
|
|
266
|
+
result = new APIClientError('REQUEST_FAILED', error instanceof Error ? error.message : 'The request failed.')
|
|
267
|
+
}
|
|
268
|
+
const token = this.state.record?.token
|
|
269
|
+
if (token && config?.headers.get('Authorization') === `Bearer ${token}` && sessionErrors.has(result.code)) {
|
|
270
|
+
this.revision++
|
|
271
|
+
Object.assign(this.state, { record: null, ready: false, expiresAt: null })
|
|
272
|
+
if (this.read()?.token === token) this.write(null)
|
|
273
|
+
this.options.onInvalidated?.()
|
|
274
|
+
}
|
|
275
|
+
return result
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private check<T>(response: AxiosResponse<ApiEnvelope<T>>): ApiEnvelope<T> {
|
|
279
|
+
if (response.data?.success !== true) {
|
|
280
|
+
throw new APIClientError(
|
|
281
|
+
response.data?.error?.code ?? 'INVALID_RESPONSE',
|
|
282
|
+
this.message(typeof response.data?.error === 'string' ? response.data.error : response.data?.error?.message, 'Invalid server response.'),
|
|
283
|
+
response.status,
|
|
284
|
+
response
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return response.data
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private validateURL(config: InternalAxiosRequestConfig): void {
|
|
291
|
+
const base = new URL(this.options.baseURL)
|
|
292
|
+
const url = this.axios.getUri(config)
|
|
293
|
+
const target = new URL(url, `${this.options.baseURL}/`)
|
|
294
|
+
const path = base.pathname.replace(/\/+$/, '')
|
|
295
|
+
if (target.origin !== base.origin || target.username || target.password
|
|
296
|
+
|| (path && target.pathname !== path && !target.pathname.startsWith(`${path}/`))) {
|
|
297
|
+
throw new APIClientError('INVALID_URL', 'Invalid API path.')
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
public sync(): void {
|
|
302
|
+
const record = this.read()
|
|
303
|
+
if (record?.token === this.state.record?.token) return
|
|
304
|
+
this.revision++
|
|
305
|
+
this.state.record = record
|
|
306
|
+
this.state.ready = false
|
|
307
|
+
this.state.expiresAt = null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
public initialize(force = false): Promise<void> {
|
|
311
|
+
if (this.pending) return this.pending
|
|
312
|
+
if (this.state.ready && this.state.expiresAt !== null && this.state.expiresAt > Date.now() && !force) return Promise.resolve()
|
|
313
|
+
const work = () => this.load()
|
|
314
|
+
this.pending = (this.options.lock ? this.options.lock(work) : work())
|
|
315
|
+
.catch(error => {
|
|
316
|
+
const result = this.error(error)
|
|
317
|
+
this.state.error = result.message
|
|
318
|
+
throw result
|
|
319
|
+
})
|
|
320
|
+
.finally(() => { this.pending = null })
|
|
321
|
+
return this.pending
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private async load(): Promise<void> {
|
|
325
|
+
const revision = this.revision
|
|
326
|
+
const assertCurrent = () => {
|
|
327
|
+
if (revision !== this.revision) throw new APIClientError('SESSION_CHANGED', 'The session has changed. Please try again.')
|
|
328
|
+
}
|
|
329
|
+
this.state.error = null
|
|
330
|
+
this.state.ready = false
|
|
331
|
+
let record = this.read() ?? this.state.record
|
|
332
|
+
const saved = record
|
|
333
|
+
let expires: unknown
|
|
334
|
+
|
|
335
|
+
if (record) {
|
|
336
|
+
try {
|
|
337
|
+
const response = await this.transport.get<ApiEnvelope<{ expires_in: number }>>('/session', {
|
|
338
|
+
headers: { Authorization: `Bearer ${record.token}` }
|
|
339
|
+
})
|
|
340
|
+
const data = this.check(response).data
|
|
341
|
+
expires = data?.expires_in
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const result = this.error(error)
|
|
344
|
+
if (!sessionErrors.has(result.code)) throw result
|
|
345
|
+
assertCurrent()
|
|
346
|
+
// An expired response must not delete a session created by another tab.
|
|
347
|
+
if (this.read()?.token === record.token) this.write(null)
|
|
348
|
+
this.state.record = null
|
|
349
|
+
record = null
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
assertCurrent()
|
|
354
|
+
if (!record) {
|
|
355
|
+
const response = await this.transport.put<ApiEnvelope<SessionRecord & { expires_in: number }>>('/session', {}, { headers: { Authorization: false } })
|
|
356
|
+
assertCurrent()
|
|
357
|
+
const data = this.check(response).data
|
|
358
|
+
if (typeof data?.id !== 'string' || !data.id || typeof data?.token !== 'string' || !data.token) {
|
|
359
|
+
throw new APIClientError('INVALID_RESPONSE', 'Invalid session creation response.')
|
|
360
|
+
}
|
|
361
|
+
record = { id: data.id, token: data.token }
|
|
362
|
+
expires = data.expires_in
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (typeof expires !== 'number' || !Number.isFinite(expires) || expires < 0) {
|
|
366
|
+
throw new APIClientError('INVALID_RESPONSE', 'Invalid session expiration information.')
|
|
367
|
+
}
|
|
368
|
+
this.duration = expires
|
|
369
|
+
Object.assign(this.state, { record: record, ready: true, expiresAt: Date.now() + expires * 1000 })
|
|
370
|
+
if (record !== saved) this.write(record)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
public clear(): void {
|
|
374
|
+
this.revision++
|
|
375
|
+
this.write(null)
|
|
376
|
+
Object.assign(this.state, { record: null, ready: false, expiresAt: null, error: null })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
public async revoke(): Promise<APIResult<null>> {
|
|
380
|
+
const result = await this.api.delete<null>('/session')
|
|
381
|
+
if (result.success) this.clear()
|
|
382
|
+
return result
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public async request<T = unknown>(method: string, path: string, data?: unknown): Promise<ApiEnvelope<T>> {
|
|
386
|
+
return (await this.axios.request<ApiEnvelope<T>>({ method: method, url: path, data: data })).data
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|