@globalbrain/sefirot 4.61.2 → 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.
@@ -1768,6 +1768,9 @@ defineExpose({
1768
1768
  :width="sheetWidth"
1769
1769
  @close="sheet.off"
1770
1770
  >
1771
+ <template v-if="$slots['sheet-title-after']" #title-after="s">
1772
+ <slot name="sheet-title-after" v-bind="s" />
1773
+ </template>
1771
1774
  <template v-if="$slots['sheet-before']" #before="s">
1772
1775
  <slot name="sheet-before" v-bind="s" />
1773
1776
  </template>
@@ -301,15 +301,15 @@ function requestClose() {
301
301
 
302
302
  // --- Slot context -----------------------------------------------------------
303
303
  //
304
- // Content passed to the `#before` / `#after` slots is declared in the page
304
+ // Content passed to the `#title-after` / `#before` / `#after` slots is declared in the page
305
305
  // that hosts the catalog, so it cannot `inject` the edit context (which is
306
306
  // provided inside `LensCatalog`, a child of that page). Expose the pieces a
307
- // slot needs as slot props instead: the resolved record id, a record-bound
308
- // partial `save`, the entity key, the sheet `mode` (so a slot can render
309
- // different content for create vs view), and `registerCreateExtension` (so a
310
- // create-mode slot can contribute extra keys to the create payload). This keeps
311
- // the generic sheet free of one-off concerns (avatar upload, social links,
312
- // linked records) while still letting a page implement them.
307
+ // slot needs as slot props instead: the rendered title, resolved record id, a
308
+ // record-bound partial `save`, the entity key, the sheet `mode` (so a slot can
309
+ // render different content for create vs view), and `registerCreateExtension`
310
+ // (so a create-mode slot can contribute extra keys to the create payload). This
311
+ // keeps the generic sheet free of one-off concerns (badges, avatar upload,
312
+ // social links, linked records) while still letting a page implement them.
313
313
 
314
314
  const resolvedId = computed(() => (props.record && edit ? edit.resolveId(props.record) : null))
315
315
 
@@ -331,6 +331,7 @@ function saveRecord(values: Record<string, any>): Promise<void> {
331
331
  }
332
332
 
333
333
  const slotProps = computed(() => ({
334
+ title: title.value,
334
335
  record: props.record ?? null,
335
336
  id: resolvedId.value,
336
337
  entity: props.entity,
@@ -357,7 +358,12 @@ const slotProps = computed(() => ({
357
358
  <SSheet :open :closable="!saving" :width="width ?? '480px'" @close="requestClose">
358
359
  <div class="LensSheet">
359
360
  <div class="header">
360
- <div class="title">{{ title }}</div>
361
+ <div class="heading">
362
+ <div class="title">{{ title }}</div>
363
+ <div v-if="$slots['title-after']" class="title-after">
364
+ <slot name="title-after" v-bind="slotProps" />
365
+ </div>
366
+ </div>
361
367
  <button
362
368
  class="close"
363
369
  type="button"
@@ -462,6 +468,15 @@ const slotProps = computed(() => ({
462
468
  border-bottom: 1px solid var(--c-divider);
463
469
  }
464
470
 
471
+ .heading {
472
+ display: flex;
473
+ align-items: center;
474
+ flex-wrap: wrap;
475
+ flex: 1;
476
+ gap: 8px;
477
+ min-width: 0;
478
+ }
479
+
465
480
  .title {
466
481
  font-size: 16px;
467
482
  font-weight: 500;
@@ -469,10 +484,17 @@ const slotProps = computed(() => ({
469
484
  color: var(--c-text-1);
470
485
  }
471
486
 
487
+ .title-after {
488
+ display: flex;
489
+ align-items: center;
490
+ flex: none;
491
+ }
492
+
472
493
  .close {
473
494
  display: flex;
474
495
  align-items: center;
475
496
  justify-content: center;
497
+ flex: none;
476
498
  width: 32px;
477
499
  height: 32px;
478
500
  border-radius: 6px;
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.61.2",
3
+ "version": "4.63.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",