@globalbrain/sefirot 4.62.0 → 4.63.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/lib/http/Http.ts +240 -16
- package/lib/stores/HttpConfig.ts +6 -0
- package/package.json +1 -1
package/lib/http/Http.ts
CHANGED
|
@@ -3,9 +3,107 @@ import { parse as parseCookie } from '@tinyhttp/cookie'
|
|
|
3
3
|
import { FetchError, type FetchOptions, type FetchResponse } from 'ofetch'
|
|
4
4
|
import { stringify } from 'qs'
|
|
5
5
|
import { saveAs } from '../support/File'
|
|
6
|
-
import { objectToFormData } from '../support/Http'
|
|
6
|
+
import { getHttpStatusCode, objectToFormData } from '../support/Http'
|
|
7
7
|
|
|
8
8
|
type Config = ReturnType<typeof import('../stores/HttpConfig').useHttpConfig>
|
|
9
|
+
type BuiltRequest = [url: string, options: FetchOptions]
|
|
10
|
+
|
|
11
|
+
export type HttpRequestOptions = FetchOptions & {
|
|
12
|
+
/**
|
|
13
|
+
* Prevent this request from recursively recovering the application session.
|
|
14
|
+
* Authentication bootstrap requests should disable session recovery.
|
|
15
|
+
*/
|
|
16
|
+
sessionRecovery?: false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const xsrfRefreshes = new WeakMap<Config, Promise<void>>()
|
|
20
|
+
const sessionRecoveries = new WeakMap<Config, Promise<boolean>>()
|
|
21
|
+
const sessionRecoveryGenerations = new WeakMap<Config, number>()
|
|
22
|
+
const relativeUrlBases = [
|
|
23
|
+
new URL('http://a.sefirot.invalid'),
|
|
24
|
+
new URL('http://b.sefirot.invalid')
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function getSessionRecoveryGeneration(config: Config): number {
|
|
28
|
+
return sessionRecoveryGenerations.get(config) ?? 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isLocalUrlReference(url: string): boolean {
|
|
32
|
+
if (URL.canParse(url)) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return relativeUrlBases.every(
|
|
37
|
+
(base) => URL.canParse(url, base)
|
|
38
|
+
&& new URL(url, base).origin === base.origin
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isReplayableBody(body: unknown): boolean {
|
|
43
|
+
if (body == null) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof body !== 'object') {
|
|
48
|
+
return ['string', 'number', 'boolean'].includes(typeof body)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
Array.isArray(body)
|
|
53
|
+
|| body instanceof Blob
|
|
54
|
+
|| body instanceof FormData
|
|
55
|
+
|| body instanceof URLSearchParams
|
|
56
|
+
|| body instanceof ArrayBuffer
|
|
57
|
+
|| ArrayBuffer.isView(body)
|
|
58
|
+
) {
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const source = body as {
|
|
63
|
+
constructor?: { name?: string }
|
|
64
|
+
pipeTo?: unknown
|
|
65
|
+
pipe?: unknown
|
|
66
|
+
next?: unknown
|
|
67
|
+
toJSON?: unknown
|
|
68
|
+
[Symbol.iterator]?: unknown
|
|
69
|
+
[Symbol.asyncIterator]?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
typeof source.pipeTo === 'function'
|
|
74
|
+
|| typeof source.pipe === 'function'
|
|
75
|
+
|| typeof source.next === 'function'
|
|
76
|
+
|| typeof source[Symbol.iterator] === 'function'
|
|
77
|
+
|| typeof source[Symbol.asyncIterator] === 'function'
|
|
78
|
+
) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return source.constructor?.name === 'Object'
|
|
83
|
+
|| typeof source.toJSON === 'function'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runSingleFlight<T>(
|
|
87
|
+
activeRequests: WeakMap<Config, Promise<T>>,
|
|
88
|
+
config: Config,
|
|
89
|
+
request: () => Promise<T>
|
|
90
|
+
): Promise<T> {
|
|
91
|
+
const activeRequest = activeRequests.get(config)
|
|
92
|
+
if (activeRequest) {
|
|
93
|
+
return activeRequest
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nextRequest = Promise.resolve().then(request)
|
|
97
|
+
activeRequests.set(config, nextRequest)
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return await nextRequest
|
|
101
|
+
} finally {
|
|
102
|
+
if (activeRequests.get(config) === nextRequest) {
|
|
103
|
+
activeRequests.delete(config)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
9
107
|
|
|
10
108
|
export class Http {
|
|
11
109
|
private config: Config
|
|
@@ -22,16 +120,77 @@ export class Http {
|
|
|
22
120
|
let xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
23
121
|
|
|
24
122
|
if (!xsrfToken) {
|
|
25
|
-
await this.
|
|
123
|
+
await this.refreshXsrfToken()
|
|
26
124
|
xsrfToken = parseCookie(document.cookie)['XSRF-TOKEN']
|
|
27
125
|
}
|
|
28
126
|
|
|
29
127
|
return xsrfToken
|
|
30
128
|
}
|
|
31
129
|
|
|
32
|
-
private
|
|
130
|
+
private isApplicationRequest(url: string, options: HttpRequestOptions = {}): boolean {
|
|
131
|
+
const pageUrl = typeof location === 'undefined' ? undefined : location.href
|
|
132
|
+
const applicationBaseUrl = this.config.baseUrl || pageUrl
|
|
133
|
+
const requestBaseUrl = Object.hasOwn(options, 'baseURL')
|
|
134
|
+
? options.baseURL
|
|
135
|
+
: this.config.baseUrl
|
|
136
|
+
if (
|
|
137
|
+
!applicationBaseUrl
|
|
138
|
+
|| (!pageUrl && isLocalUrlReference(applicationBaseUrl))
|
|
139
|
+
) {
|
|
140
|
+
return isLocalUrlReference(url)
|
|
141
|
+
&& (requestBaseUrl == null || isLocalUrlReference(requestBaseUrl))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const applicationUrl = new URL(applicationBaseUrl, pageUrl)
|
|
146
|
+
const resolvedRequestBaseUrl = requestBaseUrl
|
|
147
|
+
? new URL(requestBaseUrl, pageUrl)
|
|
148
|
+
: pageUrl
|
|
149
|
+
|
|
150
|
+
return new URL(url, resolvedRequestBaseUrl).origin === applicationUrl.origin
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async refreshXsrfToken(): Promise<void> {
|
|
157
|
+
const xsrfUrl = this.config.xsrfUrl
|
|
158
|
+
if (!xsrfUrl) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return runSingleFlight(xsrfRefreshes, this.config, async () => {
|
|
163
|
+
await this.config.client(...(await this.buildRequest(
|
|
164
|
+
xsrfUrl,
|
|
165
|
+
{ method: 'HEAD' }
|
|
166
|
+
)))
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async recoverSession(): Promise<boolean> {
|
|
171
|
+
const recoverSession = this.config.recoverSession
|
|
172
|
+
if (!recoverSession) {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return runSingleFlight(sessionRecoveries, this.config, async () => {
|
|
177
|
+
const recovered = await recoverSession()
|
|
178
|
+
if (recovered) {
|
|
179
|
+
sessionRecoveryGenerations.set(
|
|
180
|
+
this.config,
|
|
181
|
+
getSessionRecoveryGeneration(this.config) + 1
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return recovered
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async buildRequest(url: string, _options: HttpRequestOptions = {}): Promise<BuiltRequest> {
|
|
33
189
|
const { method, params, query, ...options } = _options
|
|
34
|
-
|
|
190
|
+
delete options.sessionRecovery
|
|
191
|
+
|
|
192
|
+
const xsrfToken = this.isApplicationRequest(url, _options)
|
|
193
|
+
&& ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '')
|
|
35
194
|
&& (await this.ensureXsrfToken())
|
|
36
195
|
|
|
37
196
|
const queryString = stringify(
|
|
@@ -57,25 +216,90 @@ export class Http {
|
|
|
57
216
|
]
|
|
58
217
|
}
|
|
59
218
|
|
|
60
|
-
private async
|
|
61
|
-
|
|
219
|
+
private async execute<T>(
|
|
220
|
+
url: string,
|
|
221
|
+
options: HttpRequestOptions,
|
|
222
|
+
send: (request: BuiltRequest) => Promise<T>
|
|
223
|
+
): Promise<T> {
|
|
224
|
+
const applicationRequest = this.isApplicationRequest(url, options)
|
|
225
|
+
const replayable = isReplayableBody(options.body)
|
|
226
|
+
|
|
227
|
+
const attempt = async (
|
|
228
|
+
xsrfRecovered = false,
|
|
229
|
+
sessionRecovered = false
|
|
230
|
+
): Promise<T> => {
|
|
231
|
+
const request = await this.buildRequest(url, options)
|
|
232
|
+
const sessionRecoveryGeneration = getSessionRecoveryGeneration(this.config)
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return await send(request)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const status = getHttpStatusCode(error)
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
status === 419
|
|
241
|
+
&& applicationRequest
|
|
242
|
+
&& replayable
|
|
243
|
+
&& this.config.xsrfUrl
|
|
244
|
+
&& !xsrfRecovered
|
|
245
|
+
) {
|
|
246
|
+
await this.refreshXsrfToken()
|
|
247
|
+
return attempt(true, sessionRecovered)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const canRecoverSession =
|
|
251
|
+
applicationRequest
|
|
252
|
+
&& replayable
|
|
253
|
+
&& options.sessionRecovery !== false
|
|
254
|
+
&& this.config.recoverSession != null
|
|
255
|
+
&& !sessionRecovered
|
|
256
|
+
|
|
257
|
+
if (status === 401 && canRecoverSession) {
|
|
258
|
+
const alreadyRecovered =
|
|
259
|
+
sessionRecoveryGeneration !== getSessionRecoveryGeneration(this.config)
|
|
260
|
+
|
|
261
|
+
if (alreadyRecovered || await this.recoverSession()) {
|
|
262
|
+
return attempt(xsrfRecovered, true)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return attempt()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async performRequest<T>(url: string, options: HttpRequestOptions = {}): Promise<T> {
|
|
274
|
+
return this.execute(
|
|
275
|
+
url,
|
|
276
|
+
options,
|
|
277
|
+
(request) => this.config.client(...request)
|
|
278
|
+
)
|
|
62
279
|
}
|
|
63
280
|
|
|
64
|
-
private async performRequestRaw<T>(
|
|
281
|
+
private async performRequestRaw<T>(
|
|
282
|
+
url: string,
|
|
283
|
+
options: HttpRequestOptions = {}
|
|
284
|
+
): Promise<FetchResponse<T>> {
|
|
65
285
|
// 'raw' is unavailable in useRequestFetch() during SSR, but performRequestRaw is only
|
|
66
286
|
// called by download, which runs client-side, so asserting raw's existence is safe
|
|
67
|
-
return this.
|
|
287
|
+
return this.execute(
|
|
288
|
+
url,
|
|
289
|
+
options,
|
|
290
|
+
(request) => this.config.client.raw!(...request)
|
|
291
|
+
)
|
|
68
292
|
}
|
|
69
293
|
|
|
70
|
-
async get<T = any>(url: string, options?:
|
|
294
|
+
async get<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
71
295
|
return this.performRequest<T>(url, { method: 'GET', ...options })
|
|
72
296
|
}
|
|
73
297
|
|
|
74
|
-
async head<T = any>(url: string, options?:
|
|
298
|
+
async head<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
75
299
|
return this.performRequest<T>(url, { method: 'HEAD', ...options })
|
|
76
300
|
}
|
|
77
301
|
|
|
78
|
-
async post<T = any>(url: string, body?: any, options?:
|
|
302
|
+
async post<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
79
303
|
if (body && !(body instanceof FormData)) {
|
|
80
304
|
let hasFile = false
|
|
81
305
|
|
|
@@ -99,23 +323,23 @@ export class Http {
|
|
|
99
323
|
return this.performRequest<T>(url, { method: 'POST', body, ...options })
|
|
100
324
|
}
|
|
101
325
|
|
|
102
|
-
async put<T = any>(url: string, body?: any, options?:
|
|
326
|
+
async put<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
103
327
|
return this.performRequest<T>(url, { method: 'PUT', body, ...options })
|
|
104
328
|
}
|
|
105
329
|
|
|
106
|
-
async patch<T = any>(url: string, body?: any, options?:
|
|
330
|
+
async patch<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
107
331
|
return this.performRequest<T>(url, { method: 'PATCH', body, ...options })
|
|
108
332
|
}
|
|
109
333
|
|
|
110
|
-
async delete<T = any>(url: string, options?:
|
|
334
|
+
async delete<T = any>(url: string, options?: HttpRequestOptions): Promise<T> {
|
|
111
335
|
return this.performRequest<T>(url, { method: 'DELETE', ...options })
|
|
112
336
|
}
|
|
113
337
|
|
|
114
|
-
async upload<T = any>(url: string, body?: any, options?:
|
|
338
|
+
async upload<T = any>(url: string, body?: any, options?: HttpRequestOptions): Promise<T> {
|
|
115
339
|
return this.post<T>(url, objectToFormData(body), options)
|
|
116
340
|
}
|
|
117
341
|
|
|
118
|
-
async download(url: string, options?:
|
|
342
|
+
async download(url: string, options?: HttpRequestOptions): Promise<void> {
|
|
119
343
|
const { _data: blob, headers } =
|
|
120
344
|
await this.performRequestRaw<Blob>(url, { method: 'GET', responseType: 'blob', ...options })
|
|
121
345
|
|
package/lib/stores/HttpConfig.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface HttpOptions {
|
|
|
15
15
|
baseUrl?: string
|
|
16
16
|
xsrfUrl?: string | false
|
|
17
17
|
client?: HttpClient
|
|
18
|
+
recoverSession?: false | (() => Awaitable<boolean>)
|
|
18
19
|
lang?: Lang
|
|
19
20
|
payloadKey?: string
|
|
20
21
|
headers?: () => Awaitable<Record<string, string>>
|
|
@@ -25,6 +26,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
25
26
|
const baseUrl = ref<string | undefined>(undefined)
|
|
26
27
|
const xsrfUrl = ref<string | false>('/api/csrf-cookie')
|
|
27
28
|
const client = ref<HttpClient>(ofetch)
|
|
29
|
+
const recoverSession = ref<(() => Awaitable<boolean>) | undefined>(undefined)
|
|
28
30
|
const lang = ref<Lang | undefined>(undefined)
|
|
29
31
|
const payloadKey = ref<string>('__payload__')
|
|
30
32
|
const headers = ref<() => Awaitable<Record<string, string>>>(async () => ({}))
|
|
@@ -34,6 +36,9 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
34
36
|
if (options.baseUrl != null) { baseUrl.value = options.baseUrl }
|
|
35
37
|
if (options.xsrfUrl != null) { xsrfUrl.value = options.xsrfUrl }
|
|
36
38
|
if (options.client != null) { client.value = options.client }
|
|
39
|
+
if (options.recoverSession != null) {
|
|
40
|
+
recoverSession.value = options.recoverSession || undefined
|
|
41
|
+
}
|
|
37
42
|
if (options.lang != null) { lang.value = options.lang }
|
|
38
43
|
if (options.payloadKey != null) { payloadKey.value = options.payloadKey }
|
|
39
44
|
if (options.headers != null) { headers.value = options.headers }
|
|
@@ -44,6 +49,7 @@ export const useHttpConfig = defineStore('sefirot-http-config', () => {
|
|
|
44
49
|
baseUrl: computed(() => baseUrl.value),
|
|
45
50
|
xsrfUrl: computed(() => xsrfUrl.value),
|
|
46
51
|
client: computed(() => client.value),
|
|
52
|
+
recoverSession: computed(() => recoverSession.value),
|
|
47
53
|
lang: computed(() => lang.value),
|
|
48
54
|
payloadKey: computed(() => payloadKey.value),
|
|
49
55
|
headers: computed(() => headers.value),
|