@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 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.head(this.config.xsrfUrl)
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 async buildRequest(url: string, _options: FetchOptions = {}): Promise<[string, FetchOptions]> {
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
- const xsrfToken = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '')
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 performRequest<T>(url: string, options: FetchOptions = {}): Promise<T> {
61
- return this.config.client(...(await this.buildRequest(url, options)))
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>(url: string, options: FetchOptions = {}): Promise<FetchResponse<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.config.client.raw!(...(await this.buildRequest(url, options)))
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<T> {
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?: FetchOptions): Promise<void> {
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
 
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.62.0",
3
+ "version": "4.63.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",