@deplois/session 0.0.0-stage → 0.0.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 CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @deplois/session
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
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
+