@globalbrain/sefirot 4.62.0 → 4.64.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.
@@ -71,6 +71,23 @@ export interface FieldDataBase {
71
71
  * means a null blank.
72
72
  */
73
73
  emptyValue?: any
74
+ /**
75
+ * Whether the backend translates the valueless `empty` / `notEmpty`
76
+ * filter operators for this field. The operators are strictly opt-in:
77
+ * the filter UI offers them only when the backend's field definition
78
+ * declares support, so applications whose backends don't understand
79
+ * the operators see no change. Absent means unsupported.
80
+ *
81
+ * The object form also customizes the label of the pinned "empty"
82
+ * option in the column filter dropdown (e.g. "No assignees"); a plain
83
+ * `true` keeps the generic default label.
84
+ */
85
+ emptyOperators?: boolean | EmptyOperatorsDeclaration
86
+ }
87
+
88
+ export interface EmptyOperatorsDeclaration {
89
+ labelEn?: string
90
+ labelJa?: string
74
91
  }
75
92
 
76
93
  export interface AvatarFieldData extends FieldDataBase {
@@ -9,6 +9,8 @@ export type FilterOperator =
9
9
  | 'contains'
10
10
  | 'startsWith'
11
11
  | 'endsWith'
12
+ | 'empty'
13
+ | 'notEmpty'
12
14
 
13
15
  export const FilterOperatorLabelDict: Record<FilterOperator, string> = {
14
16
  '=': 'is',
@@ -20,5 +22,54 @@ export const FilterOperatorLabelDict: Record<FilterOperator, string> = {
20
22
  'in': 'in',
21
23
  'contains': 'contains',
22
24
  'startsWith': 'starts with',
23
- 'endsWith': 'ends with'
25
+ 'endsWith': 'ends with',
26
+ 'empty': 'is empty',
27
+ 'notEmpty': 'is not empty'
28
+ }
29
+
30
+ /**
31
+ * Whether the operator forms a complete condition without a value (the
32
+ * condition value is always `null`). The filter UI renders no value input
33
+ * for such conditions.
34
+ */
35
+ export function isValuelessFilterOperator(operator: string | null): boolean {
36
+ return operator === 'empty' || operator === 'notEmpty'
37
+ }
38
+
39
+ /**
40
+ * Whether the condition tuple `[field, operator, value]` no longer
41
+ * carries an effective filter and should be cleared. A condition clears
42
+ * when its value is nullish or an empty array — scalar operators (e.g. a
43
+ * boolean `=`) carry a single value, so `false` is a real value to keep;
44
+ * only `null` clears. Valueless operators are complete without a value,
45
+ * so they never count as cleared.
46
+ */
47
+ export function isClearedFilterCondition(condition: any[]): boolean {
48
+ if (isValuelessFilterOperator(condition[1])) {
49
+ return false
50
+ }
51
+ return condition[2] == null || (Array.isArray(condition[2]) && condition[2].length === 0)
52
+ }
53
+
54
+ /**
55
+ * Normalizes the value of valueless operator conditions to `null` across
56
+ * a filters array, groups included. Filters can enter from outside the
57
+ * form UI — the `filters` prop or a hand-edited URL query — where a
58
+ * valueless condition may carry a stray value, so the catalog normalizes
59
+ * them before sending a search, preserving the `[field, operator, null]`
60
+ * wire shape.
61
+ */
62
+ export function normalizeValuelessFilterConditions(filters: any[]): any[] {
63
+ return filters.map((f) => {
64
+ if (!Array.isArray(f)) {
65
+ return f
66
+ }
67
+ const entry = f as any[]
68
+ if (entry[0] === '$and' || entry[0] === '$or') {
69
+ return [entry[0], normalizeValuelessFilterConditions(entry[1] ?? [])]
70
+ }
71
+ return isValuelessFilterOperator(entry[1]) && entry[2] !== null
72
+ ? [entry[0], entry[1], null]
73
+ : f
74
+ })
24
75
  }
@@ -9,6 +9,7 @@ import { usePower } from '../../../composables/Power'
9
9
  import { useSnackbars } from '../../../stores/Snackbars'
10
10
  import { day } from '../../../support/Day'
11
11
  import { type FieldData } from '../FieldData'
12
+ import { isClearedFilterCondition, normalizeValuelessFilterConditions } from '../FilterOperator'
12
13
  import { type LensQuery, type LensQuerySort } from '../LensQuery'
13
14
  import { type LensResult } from '../LensResult'
14
15
  import { useCatalogUrlQuerySync } from '../composables/CatalogUrlQuerySync'
@@ -676,13 +677,16 @@ function withoutIndexField(fields: string[]): string[] {
676
677
  }
677
678
 
678
679
  // Create lens filters option by combining query (free search) filters,
679
- // user selected filters, and fixed filters.
680
+ // user selected filters, and fixed filters. Filters coming from outside
681
+ // the form UI (the `filters` prop, a hand-edited URL query) may carry a
682
+ // stray value on a valueless condition, so normalize those to `null`
683
+ // before they reach the wire.
680
684
  function createInputFilters(queryFilters: any[], filters: any[]) {
681
- return [
685
+ return normalizeValuelessFilterConditions([
682
686
  ...(props.fixedFilters ?? []),
683
687
  queryFilters,
684
688
  ...filters
685
- ].filter((f) => f?.length > 0)
689
+ ].filter((f) => f?.length > 0))
686
690
  }
687
691
 
688
692
  // Re-run the search for the new query state, dropping any stashed
@@ -723,21 +727,14 @@ function onInlineFilterUpdated(filter: any[]) {
723
727
  emit('filters-updated', _filters.value)
724
728
  }
725
729
 
726
- // A filter value "clears" the filter when it's nullish or an empty
727
- // array. Scalar operators (e.g. a boolean `=`) carry a single value, so
728
- // `false` is a real value to keep — only `null` clears.
729
- function isEmptyFilterValue(value: any): boolean {
730
- return value == null || (Array.isArray(value) && value.length === 0)
731
- }
732
-
733
730
  function applyNewFilter(filter: any[]) {
734
- if (!isEmptyFilterValue(filter[2])) {
731
+ if (!isClearedFilterCondition(filter)) {
735
732
  _filters.value.push(filter)
736
733
  }
737
734
  }
738
735
 
739
736
  function replaceFilter(index: number, filter: any[]) {
740
- if (isEmptyFilterValue(filter[2])) {
737
+ if (isClearedFilterCondition(filter)) {
741
738
  _filters.value.splice(index, 1)
742
739
  return
743
740
  }
@@ -2,7 +2,7 @@
2
2
  import { computedAsync } from '@vueuse/core'
3
3
  import { computed } from 'vue'
4
4
  import { type FieldData } from '../FieldData'
5
- import { type FilterOperator, FilterOperatorLabelDict } from '../FilterOperator'
5
+ import { type FilterOperator, FilterOperatorLabelDict, isValuelessFilterOperator } from '../FilterOperator'
6
6
  import { useFieldFactory } from '../composables/FieldFactory'
7
7
 
8
8
  export interface Props {
@@ -29,7 +29,15 @@ const field = computed(() => {
29
29
  return fieldData ? fieldFactory.make(fieldData) : null
30
30
  })
31
31
 
32
+ // A valueless condition (the `empty` / `notEmpty` operators) has no
33
+ // value to show, so the chip hides the value segment entirely and never
34
+ // resolves the input.
35
+ const valueless = computed(() => isValuelessFilterOperator(props.condition.operator))
36
+
32
37
  const input = computed(() => {
38
+ if (valueless.value) {
39
+ return null
40
+ }
33
41
  return field.value?.filterInputByOperator(props.condition.operator) ?? null
34
42
  })
35
43
 
@@ -54,7 +62,7 @@ const valueText = computedAsync(async () => {
54
62
  <div class="LensCatalogStateFilterCondition">
55
63
  <div class="field">{{ fieldText }}</div>
56
64
  <div class="operator">{{ operatorText }}</div>
57
- <div class="value">
65
+ <div v-if="!valueless" class="value">
58
66
  {{ valueText }}
59
67
  </div>
60
68
  </div>
@@ -11,6 +11,7 @@ import { useData } from '../../../composables/Data'
11
11
  import { useLang, useTrans } from '../../../composables/Lang'
12
12
  import { useValidation } from '../../../composables/Validation'
13
13
  import { type FieldData } from '../FieldData'
14
+ import { isValuelessFilterOperator } from '../FilterOperator'
14
15
  import { useFieldFactory } from '../composables/FieldFactory'
15
16
  import { type FilterCondition } from './LensFormFilterCondition.vue'
16
17
  import LensFormFilterGroup, { type FilterGroup } from './LensFormFilterGroup.vue'
@@ -96,7 +97,7 @@ const fieldOptions = computed(() => {
96
97
  // }
97
98
  function lensFiltersToGroup() {
98
99
  const conditions = props.filters.length > 0
99
- ? pruneMissingFields(props.filters.map(lensConditionToCondition))
100
+ ? pruneUneditableConditions(props.filters.map(lensConditionToCondition))
100
101
  : []
101
102
 
102
103
  return {
@@ -105,15 +106,32 @@ function lensFiltersToGroup() {
105
106
  }
106
107
  }
107
108
 
108
- // Drop conditions that reference a field absent from `props.fields` (for
109
- // example a stale saved filter pointing at a field that no longer
110
- // exists). Such a condition can't be rendered or edited, and would
111
- // otherwise pass validation with a null input and silently re-emit the
112
- // stale filter on Apply. Groups left empty by pruning are removed too.
113
- function pruneMissingFields(conditions: any[]): any[] {
109
+ // Drop conditions the form cannot render or edit: ones referencing a
110
+ // field absent from `props.fields` (a stale saved filter pointing at a
111
+ // field that no longer exists), and ones whose operator the field does
112
+ // not offer (e.g. a hand-edited URL pairing `empty` with a field that
113
+ // doesn't declare support). Mounting such a condition would throw in its
114
+ // input resolution, and it would otherwise pass validation with a null
115
+ // input and silently re-emit the stale filter on Apply. Groups left
116
+ // empty by pruning are removed too.
117
+ function pruneUneditableConditions(conditions: any[]): any[] {
114
118
  return conditions
115
- .map((c) => ('connector' in c ? { ...c, conditions: pruneMissingFields(c.conditions) } : c))
116
- .filter((c) => ('connector' in c ? c.conditions.length > 0 : c.field === null || props.fields[c.field]))
119
+ .map((c) => ('connector' in c ? { ...c, conditions: pruneUneditableConditions(c.conditions) } : c))
120
+ .filter((c) => ('connector' in c ? c.conditions.length > 0 : isEditableCondition(c)))
121
+ }
122
+
123
+ function isEditableCondition(c: any): boolean {
124
+ if (c.field === null) {
125
+ return true
126
+ }
127
+ const fieldData = props.fields[c.field]
128
+ if (!fieldData) {
129
+ return false
130
+ }
131
+ if (c.operator === null) {
132
+ return true
133
+ }
134
+ return fieldFactory.make(fieldData).availableFilterOperators().includes(c.operator)
117
135
  }
118
136
 
119
137
  function lensConditionToCondition(filter: any[]) {
@@ -142,7 +160,10 @@ function groupToLensFilters(conditions: (FilterGroup | FilterCondition)[]): any[
142
160
  if ('connector' in c) {
143
161
  return [c.connector, groupToLensFilters(c.conditions)]
144
162
  }
145
- return [c.field, c.operator, c.value]
163
+ // A valueless condition always emits `null`: an initially loaded
164
+ // condition may carry a stray value (e.g. from a hand-edited URL)
165
+ // that the operator-change cast never saw.
166
+ return [c.field, c.operator, isValuelessFilterOperator(c.operator) ? null : c.value]
146
167
  })
147
168
  }
148
169
 
@@ -9,7 +9,7 @@ import { useValidation } from '../../../composables/Validation'
9
9
  import { type Option } from '../../../support/InputDropdown'
10
10
  import { required } from '../../../validation/rules'
11
11
  import { type FieldData } from '../FieldData'
12
- import { type FilterOperator, FilterOperatorLabelDict } from '../FilterOperator'
12
+ import { type FilterOperator, FilterOperatorLabelDict, isValuelessFilterOperator } from '../FilterOperator'
13
13
  import { useFieldFactory } from '../composables/FieldFactory'
14
14
  import { type FilterInput } from '../filter-inputs/FilterInput'
15
15
 
@@ -66,8 +66,12 @@ const operatorOptions = computed(() => {
66
66
 
67
67
  const input = ref(null) as Ref<FilterInput | null>
68
68
 
69
+ // A valueless condition (the `empty` / `notEmpty` operators) has no
70
+ // value control: the value cell renders nothing instead of an input.
71
+ const valueless = computed(() => isValuelessFilterOperator(model.value.operator))
72
+
69
73
  const inputComponent = computed(() => {
70
- return input.value ? markRaw(input.value.component()) : null
74
+ return input.value && !valueless.value ? markRaw(input.value.component()) : null
71
75
  })
72
76
 
73
77
  const { validation, reset: resetValidation } = useValidation(() => ({
@@ -160,7 +164,7 @@ async function resolveInput() {
160
164
  </div>
161
165
  <div class="value">
162
166
  <div v-if="input === null" class="skeleton" />
163
- <Suspense v-else :key="model.field ?? '__empty__'">
167
+ <Suspense v-else-if="!valueless" :key="model.field ?? '__empty__'">
164
168
  <component :is="inputComponent" v-model="model.value" />
165
169
  <template #fallback>
166
170
  <div class="resolving">
@@ -17,6 +17,13 @@ import * as RuleMapper from '../validation/RuleMapper'
17
17
  */
18
18
  const DEFAULT_COLUMN_WIDTH = 168
19
19
 
20
+ /**
21
+ * The option value of the pinned "empty" option in column filter
22
+ * dropdowns. A sentinel that can never collide with real option values
23
+ * coming from backend resources.
24
+ */
25
+ export const EmptyFilterOption = Symbol('empty-filter-option')
26
+
20
27
  export abstract class Field<T extends FieldData> {
21
28
  /**
22
29
  * The field context, that holds global app context
@@ -163,6 +170,35 @@ export abstract class Field<T extends FieldData> {
163
170
  return null
164
171
  }
165
172
 
173
+ /**
174
+ * Whether an `empty` condition for the given key is present in the
175
+ * filters array.
176
+ */
177
+ protected emptyFilterActiveFor(key: string, filters: any[]): boolean {
178
+ return filters.some((f) => f[0] === key && f[1] === 'empty')
179
+ }
180
+
181
+ /**
182
+ * Whether the backend declares the valueless `empty` / `notEmpty`
183
+ * filter operators for this field. See `FieldDataBase.emptyOperators`.
184
+ */
185
+ protected emptyOperatorsEnabled(): boolean {
186
+ return !!this.data.emptyOperators
187
+ }
188
+
189
+ /**
190
+ * The label for the pinned "empty" option in the column filter
191
+ * dropdown. The backend's declaration may customize it per language
192
+ * (e.g. "No assignees"); the fallback is a generic localized "None".
193
+ */
194
+ protected emptyOptionLabel(): string {
195
+ const declaration = this.data.emptyOperators
196
+ const label = typeof declaration === 'object' && declaration !== null
197
+ ? (this.ctx.lang === 'ja' ? declaration.labelJa : declaration.labelEn)
198
+ : null
199
+ return label ?? (this.ctx.lang === 'ja' ? 'なし' : 'None')
200
+ }
201
+
166
202
  /**
167
203
  * Returns the table cell definition for the field.
168
204
  */
@@ -7,9 +7,10 @@ import { type TableCell } from '../../../composables/Table'
7
7
  import { type RelatedManyFieldData } from '../FieldData'
8
8
  import { type FilterOperator } from '../FilterOperator'
9
9
  import { type ResourceFetcher } from '../ResourceFetcher'
10
+ import { EmptyFilterInput } from '../filter-inputs/EmptyFilterInput'
10
11
  import { type FilterInput } from '../filter-inputs/FilterInput'
11
12
  import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
12
- import { Field } from './Field'
13
+ import { EmptyFilterOption, Field } from './Field'
13
14
 
14
15
  export class RelatedManyField extends Field<RelatedManyFieldData> {
15
16
  fetcher: ResourceFetcher
@@ -31,7 +32,11 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
31
32
  return null
32
33
  }
33
34
 
34
- const selected = this.inFilterValueFor(this.data.key, filters)
35
+ const emptyActive = this.emptyFilterActiveFor(this.data.key, filters)
36
+
37
+ const selected = emptyActive
38
+ ? [EmptyFilterOption]
39
+ : this.inFilterValueFor(this.data.key, filters)
35
40
 
36
41
  const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
37
42
  const data = key ? res[key] : res
@@ -50,12 +55,26 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
50
55
  value: this.resolveValue(item[this.data.filterKey])
51
56
  })
52
57
 
58
+ // The pinned "empty" option filters records with no related items at
59
+ // all. It is mutually exclusive with the value options: selecting it
60
+ // swaps the whole condition to a valueless `empty`, and selecting a
61
+ // value swaps back to `in`.
62
+ if (this.emptyOperatorsEnabled()) {
63
+ options.unshift({ label: this.emptyOptionLabel(), value: EmptyFilterOption })
64
+ }
65
+
53
66
  return {
54
67
  type: 'filter',
55
68
  search: true,
56
69
  selected,
57
70
  options,
58
- onClick: (v) => { onFilterUpdated?.([this.data.key, 'in', xor(selected, [v])]) }
71
+ onClick: (v) => {
72
+ if (v === EmptyFilterOption) {
73
+ onFilterUpdated?.(emptyActive ? [this.data.key, 'in', []] : [this.data.key, 'empty', null])
74
+ return
75
+ }
76
+ onFilterUpdated?.([this.data.key, 'in', xor(emptyActive ? [] : selected, [v])])
77
+ }
59
78
  }
60
79
  }
61
80
 
@@ -105,11 +124,20 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
105
124
  const selectOne = new SelectFilterInput().options(optionsResolver)
106
125
  const selectMany = new SelectFilterInput().options(optionsResolver).multiple()
107
126
 
108
- return {
127
+ const filters: Partial<Record<FilterOperator, FilterInput>> = {
109
128
  '=': selectOne,
110
129
  '!=': selectOne,
111
130
  'in': selectMany
112
131
  }
132
+
133
+ // The valueless operators are strictly opt-in via the backend's field
134
+ // definition — see `FieldDataBase.emptyOperators`.
135
+ if (this.data.emptyOperators) {
136
+ filters.empty = new EmptyFilterInput()
137
+ filters.notEmpty = new EmptyFilterInput()
138
+ }
139
+
140
+ return filters
113
141
  }
114
142
 
115
143
  // Lens id fields serialize as `{ value, display, path? }`; filters need the
@@ -6,9 +6,10 @@ import { type TableCell } from '../../../composables/Table'
6
6
  import { type RelatedOneFieldData } from '../FieldData'
7
7
  import { type FilterOperator } from '../FilterOperator'
8
8
  import { type ResourceFetcher } from '../ResourceFetcher'
9
+ import { EmptyFilterInput } from '../filter-inputs/EmptyFilterInput'
9
10
  import { type FilterInput } from '../filter-inputs/FilterInput'
10
11
  import { SelectFilterInput } from '../filter-inputs/SelectFilterInput'
11
- import { Field } from './Field'
12
+ import { EmptyFilterOption, Field } from './Field'
12
13
 
13
14
  export class RelatedOneField extends Field<RelatedOneFieldData> {
14
15
  fetcher: ResourceFetcher
@@ -30,7 +31,11 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
30
31
  return null
31
32
  }
32
33
 
33
- const selected = this.inFilterValueFor(this.data.key, filters)
34
+ const emptyActive = this.emptyFilterActiveFor(this.data.key, filters)
35
+
36
+ const selected = emptyActive
37
+ ? [EmptyFilterOption]
38
+ : this.inFilterValueFor(this.data.key, filters)
34
39
 
35
40
  const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
36
41
  const data = key ? res[key] : res
@@ -49,12 +54,26 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
49
54
  value: this.resolveValue(item[this.data.filterKey])
50
55
  })
51
56
 
57
+ // The pinned "empty" option filters records with no related record.
58
+ // It is mutually exclusive with the value options: selecting it swaps
59
+ // the whole condition to a valueless `empty`, and selecting a value
60
+ // swaps back to `in`.
61
+ if (this.emptyOperatorsEnabled()) {
62
+ options.unshift({ label: this.emptyOptionLabel(), value: EmptyFilterOption })
63
+ }
64
+
52
65
  return {
53
66
  type: 'filter',
54
67
  search: true,
55
68
  selected,
56
69
  options,
57
- onClick: (v) => { onFilterUpdated?.([this.data.key, 'in', xor(selected, [v])]) }
70
+ onClick: (v) => {
71
+ if (v === EmptyFilterOption) {
72
+ onFilterUpdated?.(emptyActive ? [this.data.key, 'in', []] : [this.data.key, 'empty', null])
73
+ return
74
+ }
75
+ onFilterUpdated?.([this.data.key, 'in', xor(emptyActive ? [] : selected, [v])])
76
+ }
58
77
  }
59
78
  }
60
79
 
@@ -107,11 +126,20 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
107
126
  const selectOne = new SelectFilterInput().options(optionsResolver)
108
127
  const selectMany = new SelectFilterInput().options(optionsResolver).multiple()
109
128
 
110
- return {
129
+ const filters: Partial<Record<FilterOperator, FilterInput>> = {
111
130
  '=': selectOne,
112
131
  '!=': selectOne,
113
132
  'in': selectMany
114
133
  }
134
+
135
+ // The valueless operators are strictly opt-in via the backend's field
136
+ // definition — see `FieldDataBase.emptyOperators`.
137
+ if (this.data.emptyOperators) {
138
+ filters.empty = new EmptyFilterInput()
139
+ filters.notEmpty = new EmptyFilterInput()
140
+ }
141
+
142
+ return filters
115
143
  }
116
144
 
117
145
  // Lens id fields serialize as `{ value, display, path? }`; filters need the
@@ -0,0 +1,26 @@
1
+ import { type ValidationArgs } from '@vuelidate/core'
2
+ import { FilterInput } from './FilterInput'
3
+
4
+ /**
5
+ * The filter input for the valueless `empty` / `notEmpty` operators (see
6
+ * `isValuelessFilterOperator`). The condition is complete with just the
7
+ * field and the operator, so this input requires no value, renders no
8
+ * control, and always normalizes the condition value back to `null`.
9
+ */
10
+ export class EmptyFilterInput extends FilterInput {
11
+ rules(): Record<string, ValidationArgs> {
12
+ return {}
13
+ }
14
+
15
+ castValue(_value: any): any {
16
+ return null
17
+ }
18
+
19
+ async valueToText(_value: any): Promise<string> {
20
+ return ''
21
+ }
22
+
23
+ component(): any {
24
+ return null
25
+ }
26
+ }
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.64.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",