@data-fair/lib-vue 0.1.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/concept-filters.d.ts +2 -0
- package/concept-filters.js +24 -0
- package/format/bytes.d.ts +2 -0
- package/format/bytes.js +16 -0
- package/format/field.d.ts +3 -0
- package/format/field.js +28 -0
- package/locale-dayjs-global.d.ts +6 -0
- package/locale-dayjs-global.js +7 -0
- package/locale-dayjs-types.d.ts +1 -0
- package/locale-dayjs-types.js +1 -0
- package/locale-dayjs.d.ts +21 -0
- package/locale-dayjs.js +32 -0
- package/package.json +36 -0
- package/reactive-search-params-global.d.ts +2 -0
- package/reactive-search-params-global.js +7 -0
- package/reactive-search-params.d.ts +21 -0
- package/reactive-search-params.js +155 -0
- package/session.d.ts +46 -0
- package/session.js +289 -0
- package/ui-notif-global.d.ts +21 -0
- package/ui-notif-global.js +7 -0
- package/ui-notif.d.ts +44 -0
- package/ui-notif.js +38 -0
- package/ws.d.ts +8 -0
- package/ws.js +66 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// filter reactiveSearchParams to conceptFilters (params prefixed by _c_)
|
|
2
|
+
import { reactive, watch } from 'vue';
|
|
3
|
+
export function useConceptFilters(reactiveSearchParams, datasetId) {
|
|
4
|
+
const conceptFilters = reactive({});
|
|
5
|
+
const datasetFiltersPrefix = datasetId && `_d_${datasetId}_`;
|
|
6
|
+
watch(reactiveSearchParams, () => {
|
|
7
|
+
for (const key of Object.keys(reactiveSearchParams)) {
|
|
8
|
+
if (key.startsWith('_c_'))
|
|
9
|
+
conceptFilters[key] = reactiveSearchParams[key];
|
|
10
|
+
if (datasetFiltersPrefix && key.startsWith(datasetFiltersPrefix)) {
|
|
11
|
+
conceptFilters[key.replace(datasetFiltersPrefix, '')] = reactiveSearchParams[key];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
for (const key of Object.keys(conceptFilters)) {
|
|
15
|
+
if (key.startsWith('_c_') && reactiveSearchParams[key] === undefined)
|
|
16
|
+
delete conceptFilters[key];
|
|
17
|
+
if (datasetFiltersPrefix && !key.startsWith('_c_') && reactiveSearchParams[datasetFiltersPrefix + key] === undefined) {
|
|
18
|
+
delete conceptFilters[key];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}, { immediate: true });
|
|
22
|
+
return conceptFilters;
|
|
23
|
+
}
|
|
24
|
+
export default useConceptFilters;
|
package/format/bytes.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const locales = {
|
|
2
|
+
fr: [[0, 'octet'], [1, 'octets'], [1000, 'ko'], [1000 * 1000, 'Mo'], [1000 * 1000 * 1000, 'Go'], [1000 * 1000 * 1000 * 1000, 'To'], [1000 * 1000 * 1000 * 1000 * 1000, 'Po']],
|
|
3
|
+
en: [[0, 'byte'], [1, 'bytes'], [1000, 'kb'], [1000 * 1000, 'Mb'], [1000 * 1000 * 1000, 'Gb'], [1000 * 1000 * 1000 * 1000, 'Tb'], [1000 * 1000 * 1000 * 1000 * 1000, 'Pb']]
|
|
4
|
+
};
|
|
5
|
+
export function formatBytes(bytes, locale = 'fr') {
|
|
6
|
+
const bytesInt = Math.abs(typeof bytes === 'string' ? parseInt(bytes, 10) : bytes);
|
|
7
|
+
const def = locales[locale] ?? locales.en;
|
|
8
|
+
for (let i = 0; i < def.length; i++) {
|
|
9
|
+
const step = def[i][0];
|
|
10
|
+
if (bytesInt < step || i === def.length - 1) {
|
|
11
|
+
return (bytesInt / (def[i - 1][0] || 1)).toLocaleString(locale, { maximumFractionDigits: 0 }) + ' ' + def[i - 1][1];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return ''; // this is only for strict typing, but the code cannot go there, the return in the loop is always called
|
|
15
|
+
}
|
|
16
|
+
export default formatBytes;
|
package/format/field.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// TODO: use locale-dayjs for localization of date formats
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
export function formatField(item, field) {
|
|
4
|
+
const value = item[field.key];
|
|
5
|
+
if (value === undefined || value === null || value === '')
|
|
6
|
+
return '';
|
|
7
|
+
if (field['x-labels']?.['' + item[field.key]])
|
|
8
|
+
return field['x-labels']['' + item[field.key]];
|
|
9
|
+
if (field.type === 'number' || field.type === 'integer')
|
|
10
|
+
return value.toLocaleString('fr');
|
|
11
|
+
if (typeof value === 'boolean')
|
|
12
|
+
return value ? 'Oui' : 'Non';
|
|
13
|
+
if (typeof value === 'string') {
|
|
14
|
+
if (field['x-refersTo'] === 'http://schema.org/Date' ||
|
|
15
|
+
field['x-refersTo'] === 'https://schema.org/startDate' ||
|
|
16
|
+
field['x-refersTo'] === 'https://schema.org/endDate' ||
|
|
17
|
+
field['x-refersTo'] === 'http://schema.org/dateCreated') {
|
|
18
|
+
if (field.format === 'date-time')
|
|
19
|
+
return dayjs(value).format('DD/MM/YYYY, HH[h]mm');
|
|
20
|
+
else
|
|
21
|
+
return dayjs(value).format('DD/MM/YYYY');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return '' + value;
|
|
25
|
+
}
|
|
26
|
+
export function getFieldLabel(field) {
|
|
27
|
+
return field.title || field['x-originalName'] || field.key;
|
|
28
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const locale: import("vue").Ref<string, string>, dayjs: (date?: string | number | import("dayjs").Dayjs | Date | null | undefined) => import("dayjs").Dayjs & {
|
|
2
|
+
fromNow(withoutSuffix?: boolean): string;
|
|
3
|
+
from(compared: import("dayjs").ConfigType, withoutSuffix?: boolean): string;
|
|
4
|
+
toNow(withoutSuffix?: boolean): string;
|
|
5
|
+
to(compared: import("dayjs").ConfigType, withoutSuffix?: boolean): string;
|
|
6
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// same as locale-dayjs but in a module level singleton for convenience when not using SSR
|
|
2
|
+
import { getLocaleDayjs } from './locale-dayjs.js';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
if (import.meta.env?.SSR) {
|
|
5
|
+
throw new Error('this module uses a module level singleton, it cannot be used in SSR mode');
|
|
6
|
+
}
|
|
7
|
+
export const { locale, dayjs } = getLocaleDayjs();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Dayjs, ConfigType } from 'dayjs';
|
|
2
|
+
import type { Ref, App } from 'vue';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import 'dayjs/locale/fr';
|
|
5
|
+
import 'dayjs/locale/en';
|
|
6
|
+
type RelativeDayjs = Dayjs & {
|
|
7
|
+
fromNow(withoutSuffix?: boolean): string;
|
|
8
|
+
from(compared: ConfigType, withoutSuffix?: boolean): string;
|
|
9
|
+
toNow(withoutSuffix?: boolean): string;
|
|
10
|
+
to(compared: ConfigType, withoutSuffix?: boolean): string;
|
|
11
|
+
};
|
|
12
|
+
export declare function getLocaleDayjs(_locale?: Ref<string>): {
|
|
13
|
+
locale: Ref<string, string>;
|
|
14
|
+
dayjs: (date?: string | number | dayjs.Dayjs | Date | null | undefined) => RelativeDayjs;
|
|
15
|
+
};
|
|
16
|
+
export declare const localeDayjsKey: unique symbol;
|
|
17
|
+
export declare function createLocaleDayjs(locale: Ref<string>): {
|
|
18
|
+
install(app: App): void;
|
|
19
|
+
};
|
|
20
|
+
export declare function useLocaleDayjs(): ReturnType<typeof getLocaleDayjs>;
|
|
21
|
+
export default useLocaleDayjs;
|
package/locale-dayjs.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { inject, ref } from 'vue';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import 'dayjs/locale/fr';
|
|
4
|
+
import 'dayjs/locale/en';
|
|
5
|
+
import localizedFormat from 'dayjs/plugin/localizedFormat.js';
|
|
6
|
+
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
|
7
|
+
dayjs.extend(localizedFormat);
|
|
8
|
+
dayjs.extend(relativeTime);
|
|
9
|
+
// main functionality, use through the createLocaleDayjs plugin and useLocaleDayjs composable
|
|
10
|
+
// or as a global singleton through ./locale-dayjs-global.js
|
|
11
|
+
export function getLocaleDayjs(_locale) {
|
|
12
|
+
const locale = _locale ?? ref('fr');
|
|
13
|
+
return {
|
|
14
|
+
locale,
|
|
15
|
+
dayjs: (date) => {
|
|
16
|
+
return dayjs(date).locale(locale.value);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// uses pattern for SSR friendly plugin/composable, cf https://antfu.me/posts/composable-vue-vueday-2021#shared-state-ssr-friendly
|
|
21
|
+
export const localeDayjsKey = Symbol('localeDayjs');
|
|
22
|
+
export function createLocaleDayjs(locale) {
|
|
23
|
+
const localeDayjs = getLocaleDayjs(locale);
|
|
24
|
+
return { install(app) { app.provide(localeDayjsKey, localeDayjs); } };
|
|
25
|
+
}
|
|
26
|
+
export function useLocaleDayjs() {
|
|
27
|
+
const localeDayjs = inject(localeDayjsKey);
|
|
28
|
+
if (!localeDayjs)
|
|
29
|
+
throw new Error('useLocaleDayjs requires using the plugin createLocaleDayjs');
|
|
30
|
+
return localeDayjs;
|
|
31
|
+
}
|
|
32
|
+
export default useLocaleDayjs;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@data-fair/lib-vue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Composables and other utilities for Vue applications in the data-fair stack.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"**/*.js",
|
|
8
|
+
"**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"dayjs": "^1.11.13",
|
|
14
|
+
"ofetch": "^1.4.0",
|
|
15
|
+
"reconnecting-websocket": "^4.4.0",
|
|
16
|
+
"vue": "^3.5.11"
|
|
17
|
+
},
|
|
18
|
+
"peerDependenciesMeta": {
|
|
19
|
+
"reconnecting-websocket": {
|
|
20
|
+
"optional": true
|
|
21
|
+
},
|
|
22
|
+
"dayjs": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@data-fair/lib-common-types": "^0.1.0",
|
|
28
|
+
"@data-fair/lib-utils": "^0.2.0",
|
|
29
|
+
"jwt-decode": "^4.0.0",
|
|
30
|
+
"universal-cookie": "^7.2.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"vue-router": "^4.4.5"
|
|
34
|
+
},
|
|
35
|
+
"type": "module"
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// same as use-reactive-search-params.js but in a module level singleton for convenience when not using SSR
|
|
2
|
+
import { getReactiveSearchParams } from './reactive-search-params.js';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
if (import.meta.env?.SSR) {
|
|
5
|
+
throw new Error('this module uses a module level singleton, it cannot be used in SSR mode');
|
|
6
|
+
}
|
|
7
|
+
export default getReactiveSearchParams();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { App } from 'vue';
|
|
2
|
+
import type { Router } from 'vue-router';
|
|
3
|
+
export declare function getReactiveSearchParams(router?: Router): Record<string, string>;
|
|
4
|
+
export declare const reactiveSearchParamsKey: unique symbol;
|
|
5
|
+
export declare function createReactiveSearchParams(router?: Router): {
|
|
6
|
+
install(app: App): void;
|
|
7
|
+
value: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
export declare function useReactiveSearchParams(): ReturnType<typeof getReactiveSearchParams>;
|
|
10
|
+
export declare const useStringSearchParam: (key: string, options?: string | {
|
|
11
|
+
default?: string;
|
|
12
|
+
}) => import("vue").WritableComputedRef<string, string>;
|
|
13
|
+
export declare const useBooleanSearchParam: (key: string, options?: boolean | {
|
|
14
|
+
default?: boolean;
|
|
15
|
+
strings?: [string, string];
|
|
16
|
+
}) => import("vue").WritableComputedRef<boolean, boolean>;
|
|
17
|
+
export declare const useNumberSearchParam: (key: string) => import("vue").WritableComputedRef<number | null, number | null>;
|
|
18
|
+
export declare const useStringsArraySearchParam: (key: string, options?: "csv" | {
|
|
19
|
+
style?: "csv";
|
|
20
|
+
}) => import("vue").WritableComputedRef<string[], string[]>;
|
|
21
|
+
export default useReactiveSearchParams;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// inspired by useUrlSearchParams (https://github.com/vueuse/vueuse/blob/main/packages/core/useUrlSearchParams/index.ts)
|
|
2
|
+
// but even simpler, without array values, always in history mode, and shared in a app plugin
|
|
3
|
+
import { reactive, watch, inject, computed } from 'vue';
|
|
4
|
+
import Debug from 'debug';
|
|
5
|
+
const debug = Debug('reactive-search-params');
|
|
6
|
+
debug.log = console.log.bind(console);
|
|
7
|
+
const applySearchParams = (state, queryParams) => {
|
|
8
|
+
const unusedKeys = new Set(Object.keys(state));
|
|
9
|
+
for (const key of Object.keys(queryParams)) {
|
|
10
|
+
const value = queryParams[key];
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
state[key] = value;
|
|
13
|
+
unusedKeys.delete(key);
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
const lastValue = value[value.length - 1];
|
|
17
|
+
if (typeof lastValue === 'string') {
|
|
18
|
+
state[key] = lastValue;
|
|
19
|
+
unusedKeys.delete(key);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const unusedKey of unusedKeys) {
|
|
24
|
+
delete state[unusedKey];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export function getReactiveSearchParams(router) {
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
if (!import.meta.env?.SSR && !router) {
|
|
30
|
+
try {
|
|
31
|
+
// nuxt 3 way of reading router
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
router = __unctx__.get('nuxt-app').use().$router;
|
|
34
|
+
debug('using nuxt 3 router implicitly');
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// nothing to do
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const state = reactive({});
|
|
41
|
+
// 2 modes, 1 based on vue router, 1 based on window.location.search
|
|
42
|
+
if (router) {
|
|
43
|
+
debug('initialize reactive search params based on vue router');
|
|
44
|
+
watch(router.currentRoute, (route) => {
|
|
45
|
+
debug('route.query changed', route.query);
|
|
46
|
+
applySearchParams(state, route.query);
|
|
47
|
+
}, { immediate: true });
|
|
48
|
+
watch(state, () => {
|
|
49
|
+
debug('state changed', state);
|
|
50
|
+
router?.replace({ query: state });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
debug('initialize reactive search params based on window.location.search');
|
|
55
|
+
window.addEventListener('popstate', () => {
|
|
56
|
+
debug('update state based on window.location.search', window.location.search);
|
|
57
|
+
applySearchParams(state, Object.fromEntries(new URLSearchParams(window.location.search)));
|
|
58
|
+
});
|
|
59
|
+
applySearchParams(state, Object.fromEntries(new URLSearchParams(window.location.search)));
|
|
60
|
+
const updateUrl = () => {
|
|
61
|
+
debug('update url based on state', state);
|
|
62
|
+
const params = new URLSearchParams('');
|
|
63
|
+
for (const key of Object.keys(state)) {
|
|
64
|
+
const value = state[key];
|
|
65
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
66
|
+
params.set(key, value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const newQuery = params.toString();
|
|
70
|
+
const newSearch = newQuery.length > 0 ? '?' + newQuery : '';
|
|
71
|
+
if (newSearch !== window.location.search) {
|
|
72
|
+
window.history.replaceState(window.history.state, window.document.title, window.location.pathname + newSearch);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
watch(state, () => {
|
|
76
|
+
updateUrl();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
// uses pattern for SSR friendly plugin/composable, cf https://antfu.me/posts/composable-vue-vueday-2021#shared-state-ssr-friendly
|
|
82
|
+
export const reactiveSearchParamsKey = Symbol('reactiveSearchParams');
|
|
83
|
+
export function createReactiveSearchParams(router) {
|
|
84
|
+
const reactiveSearchParams = getReactiveSearchParams(router);
|
|
85
|
+
return {
|
|
86
|
+
install(app) { app.provide(reactiveSearchParamsKey, reactiveSearchParams); },
|
|
87
|
+
value: reactiveSearchParams
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function useReactiveSearchParams() {
|
|
91
|
+
const reactiveSearchParams = inject(reactiveSearchParamsKey);
|
|
92
|
+
if (!reactiveSearchParams)
|
|
93
|
+
throw new Error('useReactiveSearchParams requires using the plugin createReactiveSearchParams');
|
|
94
|
+
return reactiveSearchParams;
|
|
95
|
+
}
|
|
96
|
+
export const useStringSearchParam = (key, options = {}) => {
|
|
97
|
+
const reactiveSearchParams = useReactiveSearchParams();
|
|
98
|
+
const defaultValue = typeof options === 'string' ? options : (options.default ?? '');
|
|
99
|
+
return computed({
|
|
100
|
+
get: () => reactiveSearchParams[key] ?? defaultValue,
|
|
101
|
+
set: (value) => {
|
|
102
|
+
if (value === defaultValue)
|
|
103
|
+
delete reactiveSearchParams[key];
|
|
104
|
+
else
|
|
105
|
+
reactiveSearchParams[key] = value;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
export const useBooleanSearchParam = (key, options = {}) => {
|
|
110
|
+
const reactiveSearchParams = useReactiveSearchParams();
|
|
111
|
+
const defaultValue = typeof options === 'boolean' ? options : (options.default ?? false);
|
|
112
|
+
const strings = (typeof options !== 'boolean' && options.strings) || ['1', '0'];
|
|
113
|
+
return computed({
|
|
114
|
+
get: () => key in reactiveSearchParams ? reactiveSearchParams[key] === strings[0] : defaultValue,
|
|
115
|
+
set: (value) => {
|
|
116
|
+
if (value === defaultValue)
|
|
117
|
+
delete reactiveSearchParams[key];
|
|
118
|
+
else
|
|
119
|
+
reactiveSearchParams[key] = value ? strings[0] : strings[1];
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
export const useNumberSearchParam = (key) => {
|
|
124
|
+
const reactiveSearchParams = useReactiveSearchParams();
|
|
125
|
+
return computed({
|
|
126
|
+
get: () => {
|
|
127
|
+
if (key in reactiveSearchParams) {
|
|
128
|
+
const value = Number(reactiveSearchParams[key]);
|
|
129
|
+
if (!isNaN(value))
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
},
|
|
134
|
+
set: (value) => {
|
|
135
|
+
if (value === null)
|
|
136
|
+
delete reactiveSearchParams[key];
|
|
137
|
+
else
|
|
138
|
+
reactiveSearchParams[key] = '' + value;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
export const useStringsArraySearchParam = (key, options = {}) => {
|
|
143
|
+
const reactiveSearchParams = useReactiveSearchParams();
|
|
144
|
+
// const style = typeof options === 'string' ? options : (options.style ?? 'csv')
|
|
145
|
+
return computed({
|
|
146
|
+
get: () => reactiveSearchParams[key] ? reactiveSearchParams[key]?.split(',') : [],
|
|
147
|
+
set: (value) => {
|
|
148
|
+
if (value.length === 0)
|
|
149
|
+
delete reactiveSearchParams[key];
|
|
150
|
+
else
|
|
151
|
+
reactiveSearchParams[key] = value.join(',');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
export default useReactiveSearchParams;
|
package/session.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type IncomingMessage } from 'node:http';
|
|
2
|
+
import { type Ref, App } from 'vue';
|
|
3
|
+
import { type RouteLocation } from 'vue-router';
|
|
4
|
+
import { type fetch } from 'ofetch';
|
|
5
|
+
import { type SessionState, type SessionStateAuthenticated } from '@data-fair/lib-common-types/session/index.js';
|
|
6
|
+
interface GenericCookies {
|
|
7
|
+
get: (key: string) => string | undefined;
|
|
8
|
+
set: (key: string, value: string, options?: Record<string, any>) => void;
|
|
9
|
+
remove: (key: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface SessionOptions {
|
|
12
|
+
route?: RouteLocation;
|
|
13
|
+
sitePath?: string;
|
|
14
|
+
directoryUrl?: string;
|
|
15
|
+
logoutRedirectUrl?: string;
|
|
16
|
+
req?: IncomingMessage;
|
|
17
|
+
cookies?: GenericCookies;
|
|
18
|
+
customFetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
export interface Session {
|
|
21
|
+
state: SessionState;
|
|
22
|
+
loginUrl: (redirect?: string, extraParams?: Record<string, string>, immediateRedirect?: true) => string;
|
|
23
|
+
login: (redirect?: string, extraParams?: Record<string, string>, immediateRedirect?: true) => void;
|
|
24
|
+
logout: (redirect?: string) => Promise<void>;
|
|
25
|
+
switchOrganization: (org: string | null, dep?: string) => void;
|
|
26
|
+
setAdminMode: (adminMode: boolean, redirect?: string) => Promise<void>;
|
|
27
|
+
asAdmin: (user: any | null) => Promise<void>;
|
|
28
|
+
cancelDeletion: () => Promise<void>;
|
|
29
|
+
keepalive: () => Promise<void>;
|
|
30
|
+
switchDark: (value: boolean) => void;
|
|
31
|
+
switchLang: (value: string) => void;
|
|
32
|
+
topLocation: Ref<Location | undefined>;
|
|
33
|
+
options: SessionOptions;
|
|
34
|
+
}
|
|
35
|
+
export type SessionAuthenticated = Session & {
|
|
36
|
+
state: SessionStateAuthenticated;
|
|
37
|
+
};
|
|
38
|
+
export declare function getSession(initOptions: SessionOptions): Promise<Session>;
|
|
39
|
+
export declare const sessionKey: unique symbol;
|
|
40
|
+
export declare function createSession(initOptions: SessionOptions): Promise<{
|
|
41
|
+
install(app: App): void;
|
|
42
|
+
value: Session;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function useSession(): Session;
|
|
45
|
+
export declare function useSessionAuthenticated(errorBuilder?: () => any): SessionAuthenticated;
|
|
46
|
+
export default useSession;
|
package/session.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { reactive, computed, watch, inject } from 'vue';
|
|
2
|
+
import { ofetch } from 'ofetch';
|
|
3
|
+
import { jwtDecode } from 'jwt-decode';
|
|
4
|
+
import cookiesModule from 'universal-cookie';
|
|
5
|
+
import Debug from 'debug';
|
|
6
|
+
const Cookies = cookiesModule;
|
|
7
|
+
const debug = Debug('session');
|
|
8
|
+
debug.log = console.log.bind(console);
|
|
9
|
+
function jwtDecodeAlive(jwt) {
|
|
10
|
+
if (!jwt)
|
|
11
|
+
return;
|
|
12
|
+
const decoded = jwtDecode(jwt);
|
|
13
|
+
if (!decoded)
|
|
14
|
+
return;
|
|
15
|
+
const now = Math.ceil(Date.now().valueOf() / 1000);
|
|
16
|
+
if (typeof decoded.exp !== 'undefined' && decoded.exp < now) {
|
|
17
|
+
console.error(`token expired: ${decoded.exp}<${now}, ${JSON.stringify(decoded)}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (typeof decoded.nbf !== 'undefined' && decoded.nbf > now) {
|
|
21
|
+
console.warn(`token not yet valid: ${decoded.nbf}>${now}, ${JSON.stringify(decoded)}`);
|
|
22
|
+
// do not return null here, this is probably a false flag due to a slightly mismatched clock
|
|
23
|
+
// return null
|
|
24
|
+
}
|
|
25
|
+
return decoded;
|
|
26
|
+
}
|
|
27
|
+
const getTopLocation = () => {
|
|
28
|
+
try {
|
|
29
|
+
return window.top ? window.top.location : window.location;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return window.location;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const goTo = (url) => {
|
|
36
|
+
const topLocation = getTopLocation();
|
|
37
|
+
if (topLocation == null) {
|
|
38
|
+
throw new TypeError('session.goTo was called without access to the window object or its location');
|
|
39
|
+
}
|
|
40
|
+
if (url)
|
|
41
|
+
topLocation.href = url;
|
|
42
|
+
else
|
|
43
|
+
topLocation.reload();
|
|
44
|
+
};
|
|
45
|
+
const defaultOptions = { directoryUrl: '/simple-directory', sitePath: '' };
|
|
46
|
+
export async function getSession(initOptions) {
|
|
47
|
+
const options = { ...defaultOptions, ...initOptions };
|
|
48
|
+
const cookiesPath = options.sitePath + '/';
|
|
49
|
+
debug(`init directoryUrl=${options.directoryUrl}, cookiesPath=${cookiesPath}`);
|
|
50
|
+
const ssr = !!options.req;
|
|
51
|
+
if (ssr)
|
|
52
|
+
debug('run in SSR context');
|
|
53
|
+
const customFetch = initOptions?.customFetch ?? ofetch;
|
|
54
|
+
// use vue-router to detect page change and maintain a reference to the current page location
|
|
55
|
+
// top page if we are in iframe context
|
|
56
|
+
const topLocation = computed(() => {
|
|
57
|
+
if (ssr)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (options.route?.fullPath) { /* empty */ } // adds reactivity
|
|
60
|
+
const location = getTopLocation();
|
|
61
|
+
debug('update location based on route change', location);
|
|
62
|
+
return location;
|
|
63
|
+
});
|
|
64
|
+
// the core state of the session that is filled by reading cookies
|
|
65
|
+
const state = reactive({});
|
|
66
|
+
// cookies are the source of truth and this information is transformed into the state reactive object
|
|
67
|
+
const cookies = initOptions?.cookies ?? new Cookies(options.req?.headers.cookie);
|
|
68
|
+
const readCookies = () => {
|
|
69
|
+
const darkCookie = cookies.get('theme_dark');
|
|
70
|
+
state.dark = darkCookie === '1' || darkCookie === 'true';
|
|
71
|
+
const langCookie = cookies.get('i18n_lang');
|
|
72
|
+
if (langCookie)
|
|
73
|
+
state.lang = langCookie;
|
|
74
|
+
else
|
|
75
|
+
delete state.lang;
|
|
76
|
+
const idToken = cookies.get('id_token');
|
|
77
|
+
const user = jwtDecodeAlive(idToken);
|
|
78
|
+
if (!user) {
|
|
79
|
+
delete state.user;
|
|
80
|
+
delete state.organization;
|
|
81
|
+
delete state.account;
|
|
82
|
+
delete state.accountRole;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// this is to prevent null values that are put by SD versions that do not strictly respect their schema
|
|
86
|
+
for (const org of user.organizations) {
|
|
87
|
+
if (!org.department) {
|
|
88
|
+
delete org.department;
|
|
89
|
+
delete org.departmentName;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
state.user = user;
|
|
93
|
+
const organizationId = cookies.get('id_token_org');
|
|
94
|
+
const departmentId = cookies.get('id_token_dep');
|
|
95
|
+
if (organizationId) {
|
|
96
|
+
if (departmentId) {
|
|
97
|
+
state.organization = state.user.organizations.find(o => o.id === organizationId && o.department === departmentId);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
state.organization = state.user.organizations.find(o => o.id === organizationId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
delete state.organization;
|
|
105
|
+
}
|
|
106
|
+
if (state.organization) {
|
|
107
|
+
state.account = {
|
|
108
|
+
type: 'organization',
|
|
109
|
+
id: state.organization.id,
|
|
110
|
+
name: state.organization.name,
|
|
111
|
+
department: state.organization.department,
|
|
112
|
+
departmentName: state.organization.departmentName
|
|
113
|
+
};
|
|
114
|
+
state.accountRole = state.organization.role;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
state.account = {
|
|
118
|
+
type: 'user',
|
|
119
|
+
id: state.user.id,
|
|
120
|
+
name: state.user.name
|
|
121
|
+
};
|
|
122
|
+
state.accountRole = 'admin';
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
readCookies();
|
|
126
|
+
debug('initial state', state);
|
|
127
|
+
if (!ssr) {
|
|
128
|
+
// sessionData is also stored in localStorage as a way to access it in simpler pages that do not require use-session
|
|
129
|
+
// and in order to listen to storage event from other contexts and sync session info accross windows and tabs
|
|
130
|
+
const storageListener = (event) => {
|
|
131
|
+
if (event.key === 'sd-session' + options.sitePath)
|
|
132
|
+
readCookies();
|
|
133
|
+
};
|
|
134
|
+
window.addEventListener('storage', storageListener);
|
|
135
|
+
// we cannot use onUnmounted here or we get warnings "onUnmounted is called when there is no active component instance to be associated with. "
|
|
136
|
+
// TODO: should we have another cleanup mechanism ?
|
|
137
|
+
// onUnmounted(() => { window.removeEventListener('storage', storageListener) })
|
|
138
|
+
// trigger some full page refresh when some key session elements are changed
|
|
139
|
+
// the danger of simply using reactivity is too high, data must be re-fetched, etc.
|
|
140
|
+
watch(() => state.account, (account, oldAccount) => {
|
|
141
|
+
if (account?.type !== oldAccount?.type || account?.id !== oldAccount?.id || account?.department !== oldAccount?.department) {
|
|
142
|
+
goTo(null);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
watch(() => state.lang, () => {
|
|
146
|
+
goTo(null);
|
|
147
|
+
});
|
|
148
|
+
watch(() => state.dark, () => {
|
|
149
|
+
goTo(null);
|
|
150
|
+
});
|
|
151
|
+
watch(state, (state) => {
|
|
152
|
+
if (!ssr) {
|
|
153
|
+
window.localStorage.setItem('sd-session' + options.sitePath, JSON.stringify(state));
|
|
154
|
+
}
|
|
155
|
+
debug('state changed', state);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// login can be performed as a simple link (please use target=top) or as a function
|
|
159
|
+
function loginUrl(redirect, extraParams = {}, immediateRedirect = true) {
|
|
160
|
+
// login can also be used to redirect user immediately if he is already logged
|
|
161
|
+
if (redirect && state.user && immediateRedirect)
|
|
162
|
+
return redirect;
|
|
163
|
+
if (!redirect && topLocation.value)
|
|
164
|
+
redirect = topLocation.value.href;
|
|
165
|
+
let url = `${options.directoryUrl}/login?redirect=${encodeURIComponent(redirect ?? '')}`;
|
|
166
|
+
Object.keys(extraParams).filter(key => ![null, undefined, ''].includes(extraParams[key])).forEach((key) => {
|
|
167
|
+
url += `&${key}=${encodeURIComponent(extraParams[key])}`;
|
|
168
|
+
});
|
|
169
|
+
return url;
|
|
170
|
+
}
|
|
171
|
+
const login = (redirect, extraParams = {}, immediateRedirect = true) => {
|
|
172
|
+
goTo(loginUrl(redirect, extraParams, immediateRedirect));
|
|
173
|
+
};
|
|
174
|
+
const logout = async (redirect) => {
|
|
175
|
+
await customFetch(`${options.directoryUrl}/api/auth`, { method: 'DELETE' });
|
|
176
|
+
// sometimes server side cookie deletion is not applied immediately in browser local js context
|
|
177
|
+
// so we do it here to
|
|
178
|
+
cookies.remove('id_token');
|
|
179
|
+
cookies.remove('id_token_org');
|
|
180
|
+
cookies.remove('id_token_dep');
|
|
181
|
+
goTo(redirect ?? options.logoutRedirectUrl ?? null);
|
|
182
|
+
};
|
|
183
|
+
const switchOrganization = (org, dep) => {
|
|
184
|
+
if (org)
|
|
185
|
+
cookies.set('id_token_org', org, { path: cookiesPath });
|
|
186
|
+
else
|
|
187
|
+
cookies.remove('id_token_org');
|
|
188
|
+
if (dep)
|
|
189
|
+
cookies.set('id_token_dep', dep, { path: cookiesPath });
|
|
190
|
+
else
|
|
191
|
+
cookies.remove('id_token_dep');
|
|
192
|
+
readCookies();
|
|
193
|
+
};
|
|
194
|
+
const setAdminMode = async (adminMode, redirect) => {
|
|
195
|
+
if (adminMode) {
|
|
196
|
+
const params = { adminMode: 'true' };
|
|
197
|
+
if (state.user != null)
|
|
198
|
+
params.email = state.user.email;
|
|
199
|
+
const url = loginUrl(redirect, params, true);
|
|
200
|
+
goTo(url);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await customFetch(`${options.directoryUrl}/api/auth/adminmode`, { method: 'DELETE' });
|
|
204
|
+
goTo(redirect ?? null);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const asAdmin = async (user) => {
|
|
208
|
+
if (user) {
|
|
209
|
+
await customFetch(`${options.directoryUrl}/api/auth/asadmin`, { method: 'POST', body: user });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
await customFetch(`${options.directoryUrl}/api/auth/asadmin`, { method: 'DELETE' });
|
|
213
|
+
}
|
|
214
|
+
readCookies();
|
|
215
|
+
};
|
|
216
|
+
const cancelDeletion = async () => {
|
|
217
|
+
if (state.user == null)
|
|
218
|
+
return;
|
|
219
|
+
await customFetch(`${options.directoryUrl}/api/users/${state.user.id}`, { method: 'PATCH', body: ({ plannedDeletion: null }) });
|
|
220
|
+
readCookies();
|
|
221
|
+
};
|
|
222
|
+
const switchDark = (value) => {
|
|
223
|
+
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
|
224
|
+
cookies.set('theme_dark', `${value}`, { maxAge, path: cookiesPath });
|
|
225
|
+
readCookies();
|
|
226
|
+
};
|
|
227
|
+
const switchLang = (value) => {
|
|
228
|
+
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
|
229
|
+
cookies.set('i18n_lang', value, { maxAge, path: cookiesPath });
|
|
230
|
+
readCookies();
|
|
231
|
+
};
|
|
232
|
+
const keepalive = async () => {
|
|
233
|
+
if (state.user == null)
|
|
234
|
+
return;
|
|
235
|
+
window.localStorage.setItem('sd-keepalive' + options.sitePath, `${new Date().getTime()}`);
|
|
236
|
+
await customFetch(`${options.directoryUrl}/api/auth/keepalive`, { method: 'POST' });
|
|
237
|
+
readCookies();
|
|
238
|
+
};
|
|
239
|
+
// immediately performs a keepalive, but only on top windows (not iframes or popups)
|
|
240
|
+
// and only if it was not done very recently (maybe from a refreshed page next to this one)
|
|
241
|
+
if (!ssr && window.top === window.self) {
|
|
242
|
+
const lastKeepalive = window.localStorage.getItem('sd-keepalive' + options.sitePath);
|
|
243
|
+
if (!lastKeepalive || (new Date().getTime() - Number(lastKeepalive)) > 10000) {
|
|
244
|
+
await keepalive();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const session = {
|
|
248
|
+
state,
|
|
249
|
+
loginUrl,
|
|
250
|
+
login,
|
|
251
|
+
logout,
|
|
252
|
+
switchOrganization,
|
|
253
|
+
setAdminMode,
|
|
254
|
+
asAdmin,
|
|
255
|
+
cancelDeletion,
|
|
256
|
+
keepalive,
|
|
257
|
+
switchDark,
|
|
258
|
+
switchLang,
|
|
259
|
+
topLocation,
|
|
260
|
+
options
|
|
261
|
+
};
|
|
262
|
+
return session;
|
|
263
|
+
}
|
|
264
|
+
// uses pattern for SSR friendly plugin/composable, cf https://antfu.me/posts/composable-vue-vueday-2021#shared-state-ssr-friendly
|
|
265
|
+
export const sessionKey = Symbol('session');
|
|
266
|
+
export async function createSession(initOptions) {
|
|
267
|
+
const session = await getSession(initOptions);
|
|
268
|
+
return {
|
|
269
|
+
install(app) { app.provide(sessionKey, session); },
|
|
270
|
+
value: session
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export function useSession() {
|
|
274
|
+
const session = inject(sessionKey);
|
|
275
|
+
if (!session)
|
|
276
|
+
throw new Error('useSession requires using the plugin createSession');
|
|
277
|
+
return session;
|
|
278
|
+
}
|
|
279
|
+
export function useSessionAuthenticated(errorBuilder) {
|
|
280
|
+
const session = useSession();
|
|
281
|
+
if (!session.state.user) {
|
|
282
|
+
if (errorBuilder)
|
|
283
|
+
throw errorBuilder();
|
|
284
|
+
else
|
|
285
|
+
throw new Error('useSessionAuthenticated requires a logged in user');
|
|
286
|
+
}
|
|
287
|
+
return session;
|
|
288
|
+
}
|
|
289
|
+
export default useSession;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
declare const _default: {
|
|
2
|
+
current: import("vue").Ref<{
|
|
3
|
+
type?: ("default" | "info" | "success" | "warning") | undefined;
|
|
4
|
+
msg: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "error";
|
|
7
|
+
msg: string;
|
|
8
|
+
error: any;
|
|
9
|
+
errorMsg: string;
|
|
10
|
+
} | null, import("./ui-notif.js").UiNotif | {
|
|
11
|
+
type?: ("default" | "info" | "success" | "warning") | undefined;
|
|
12
|
+
msg: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "error";
|
|
15
|
+
msg: string;
|
|
16
|
+
error: any;
|
|
17
|
+
errorMsg: string;
|
|
18
|
+
} | null>;
|
|
19
|
+
send: (partialNotif: import("./ui-notif.js").PartialUiNotif) => void;
|
|
20
|
+
};
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// same as use-ui-notif.js but in a module level singleton for convenience when not using SSR
|
|
2
|
+
import { getUiNotif } from './ui-notif.js';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
if (import.meta.env?.SSR) {
|
|
5
|
+
throw new Error('this module uses a module level singleton, it cannot be used in SSR mode');
|
|
6
|
+
}
|
|
7
|
+
export default getUiNotif();
|
package/ui-notif.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Ref, App } from 'vue';
|
|
2
|
+
export type UiNotif = UiNotifBase | UiNotifError;
|
|
3
|
+
export interface PartialUiNotif {
|
|
4
|
+
type?: 'default' | 'info' | 'success' | 'warning' | 'error';
|
|
5
|
+
msg: string;
|
|
6
|
+
error?: any;
|
|
7
|
+
errorMsg?: string;
|
|
8
|
+
}
|
|
9
|
+
interface UiNotifBase {
|
|
10
|
+
type?: 'default' | 'info' | 'success' | 'warning';
|
|
11
|
+
msg: string;
|
|
12
|
+
}
|
|
13
|
+
interface UiNotifError {
|
|
14
|
+
type: 'error';
|
|
15
|
+
msg: string;
|
|
16
|
+
error: any;
|
|
17
|
+
errorMsg: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const getUiNotif: () => {
|
|
20
|
+
current: Ref<{
|
|
21
|
+
type?: ("default" | "info" | "success" | "warning") | undefined;
|
|
22
|
+
msg: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "error";
|
|
25
|
+
msg: string;
|
|
26
|
+
error: any;
|
|
27
|
+
errorMsg: string;
|
|
28
|
+
} | null, UiNotif | {
|
|
29
|
+
type?: ("default" | "info" | "success" | "warning") | undefined;
|
|
30
|
+
msg: string;
|
|
31
|
+
} | {
|
|
32
|
+
type: "error";
|
|
33
|
+
msg: string;
|
|
34
|
+
error: any;
|
|
35
|
+
errorMsg: string;
|
|
36
|
+
} | null>;
|
|
37
|
+
send: (partialNotif: PartialUiNotif) => void;
|
|
38
|
+
};
|
|
39
|
+
export declare const uiNotifKey: unique symbol;
|
|
40
|
+
export declare function createUiNotif(): {
|
|
41
|
+
install(app: App): void;
|
|
42
|
+
};
|
|
43
|
+
export declare function useUiNotif(): ReturnType<typeof getUiNotif>;
|
|
44
|
+
export default useUiNotif;
|
package/ui-notif.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ref, inject } from 'vue';
|
|
2
|
+
import inIframe from '@data-fair/lib-utils/in-iframe.js';
|
|
3
|
+
function getFullNotif(notif) {
|
|
4
|
+
if (typeof notif === 'string')
|
|
5
|
+
return { msg: notif, type: 'default' };
|
|
6
|
+
if (notif.error) {
|
|
7
|
+
notif.type = 'error';
|
|
8
|
+
notif.errorMsg = (notif.error.response && (notif.error.response.data || notif.error.response.status)) || notif.error.message || notif.error;
|
|
9
|
+
}
|
|
10
|
+
notif.type = notif.type || 'default';
|
|
11
|
+
if (inIframe) {
|
|
12
|
+
window.top?.postMessage({ vIframe: true, uiNotification: notif }, '*');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
console.log('notification', notif);
|
|
16
|
+
}
|
|
17
|
+
throw new Error('invalid UI notification');
|
|
18
|
+
}
|
|
19
|
+
export const getUiNotif = () => {
|
|
20
|
+
const current = ref(null);
|
|
21
|
+
const send = (partialNotif) => {
|
|
22
|
+
current.value = getFullNotif(partialNotif);
|
|
23
|
+
};
|
|
24
|
+
return { current, send };
|
|
25
|
+
};
|
|
26
|
+
// uses pattern for SSR friendly plugin/composable, cf https://antfu.me/posts/composable-vue-vueday-2021#shared-state-ssr-friendly
|
|
27
|
+
export const uiNotifKey = Symbol('uiNotif');
|
|
28
|
+
export function createUiNotif() {
|
|
29
|
+
const uiNotif = getUiNotif();
|
|
30
|
+
return { install(app) { app.provide(uiNotifKey, uiNotif); } };
|
|
31
|
+
}
|
|
32
|
+
export function useUiNotif() {
|
|
33
|
+
const session = inject(uiNotifKey);
|
|
34
|
+
if (!session)
|
|
35
|
+
throw new Error('useUiNotif requires using the plugin createUiNotif');
|
|
36
|
+
return session;
|
|
37
|
+
}
|
|
38
|
+
export default useUiNotif;
|
package/ws.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import reconnectingWebSocketModule from 'reconnecting-websocket';
|
|
2
|
+
export declare function useWS(path: string): {
|
|
3
|
+
opened: import("vue").Ref<boolean, boolean>;
|
|
4
|
+
ws: reconnectingWebSocketModule.default;
|
|
5
|
+
subscribe: <T>(channel: string, listener: (message: T) => void) => void;
|
|
6
|
+
unsubscribe: (channel: string, listener: (message: any) => void) => void;
|
|
7
|
+
} | undefined;
|
|
8
|
+
export default useWS;
|
package/ws.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import reconnectingWebSocketModule from 'reconnecting-websocket';
|
|
2
|
+
import { ref, reactive, onScopeDispose } from 'vue';
|
|
3
|
+
const ReconnectingWebSocket = reconnectingWebSocketModule;
|
|
4
|
+
const getWS = (path) => {
|
|
5
|
+
if (!window.WebSocket)
|
|
6
|
+
return;
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
if (import.meta.env?.SSR)
|
|
9
|
+
return;
|
|
10
|
+
const url = (window.location.origin + path).replace('http:', 'ws:').replace('https:', 'wss:');
|
|
11
|
+
const ws = new ReconnectingWebSocket(url);
|
|
12
|
+
const subscriptions = reactive({});
|
|
13
|
+
const opened = ref(false);
|
|
14
|
+
ws.addEventListener('open', () => {
|
|
15
|
+
opened.value = true;
|
|
16
|
+
for (const channel of Object.keys(subscriptions)) {
|
|
17
|
+
if (subscriptions[channel].length) {
|
|
18
|
+
ws.send(JSON.stringify({ type: 'subscribe', channel }));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
ws.send(JSON.stringify({ type: 'unsubscribe', channel }));
|
|
22
|
+
delete subscriptions[channel];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
ws.addEventListener('close', () => {
|
|
27
|
+
opened.value = false;
|
|
28
|
+
});
|
|
29
|
+
ws.onmessage = (event) => {
|
|
30
|
+
const body = JSON.parse(event.data);
|
|
31
|
+
if (body.type === 'message') {
|
|
32
|
+
if (subscriptions[body.channel]?.length) {
|
|
33
|
+
for (const listener of subscriptions[body.channel]) {
|
|
34
|
+
listener(body.data);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
function subscribe(channel, listener) {
|
|
40
|
+
if (!subscriptions[channel]) {
|
|
41
|
+
subscriptions[channel] = [];
|
|
42
|
+
if (opened.value)
|
|
43
|
+
ws.send(JSON.stringify({ type: 'subscribe', channel }));
|
|
44
|
+
}
|
|
45
|
+
subscriptions[channel].push(listener);
|
|
46
|
+
onScopeDispose(() => {
|
|
47
|
+
unsubscribe(channel, listener);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const unsubscribe = (channel, listener) => {
|
|
51
|
+
if (subscriptions[channel]) {
|
|
52
|
+
subscriptions[channel] = subscriptions[channel].filter(l => l !== listener);
|
|
53
|
+
if (subscriptions[channel].length === 0 && opened.value) {
|
|
54
|
+
ws.send(JSON.stringify({ type: 'unsubscribe', channel }));
|
|
55
|
+
delete subscriptions[channel];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return { opened, ws, subscribe, unsubscribe };
|
|
60
|
+
};
|
|
61
|
+
const sockets = {};
|
|
62
|
+
export function useWS(path) {
|
|
63
|
+
sockets[path] = sockets[path] ?? getWS(path);
|
|
64
|
+
return sockets[path];
|
|
65
|
+
}
|
|
66
|
+
export default useWS;
|