Files
VRCX/src/service/request.js
Natsumi 3324d0d279 Upgrade to Vue3 and Element Plus (#1374)
* Update Vue devtools

* upgrade vue pinia element-plus vue-i18n, add vite

* fix: i18n

* global components

* change v-deep

* upgrade vue-lazyload

* data table

* update enlint and safe-dialog

* package.json and vite.config.js

* el-icon

* el-message

* vue 2 -> vue3 migration changes

* $pinia

* dialog

* el-popover slot

* lint

* chore

* slot

* scss

* remote state access

* misc

* jsconfig

* el-button size mini -> small

* :model-value

* ElMessageBox

* datatable

* remove v-lazyload

* template #dropdown

* mini -> small

* css

* byebye hideTooltips

* use sass-embedded

* Update SQLite, remove unneeded libraries

* Fix shift remove local avatar favorites

* Electron arm64

* arm64 support

* bye pug

* f-word vite hah

* misc

* remove safe dialog component

* Add self invite to launch dialog

* Fix errors

* Icons 1

* improve localfavorite loading performance

* improve favorites world item performance

* dialog visibility changes for Element Plus

* clear element plus error

* import performance

* revert App.vue hah

* hah

* Revert "Add self invite to launch dialog"

This reverts commit 4801cfad58.

* Toggle self invite/open in-game

* Self invite on launch dialog

* el-button icon

* el-icon

* fix user dialog tab switching logic

* fix PlayerList

* Formatting changes

* More icons

* Fix friend log table

* loading margin

* fix markdown

* fix world dialog tab switching issue

* Fixes and formatting

* fix: global i18n.t export

* fix favorites world tab not working

* Create instance, displayName

* Remove group members sort by userId

* Fix loading dialog tabs on swtich

* Star

* charts console.warn

* wip: fix charts

* wip: fix charts

* wip: charts composables

* fix favorite item tooltip warning

* Fixes and formatting

* Clean up image dialogs

* Remove unused method

* Fix platform/size border

* Fix platform/size border

* $vr

* fix friendExportDialogVisible binding

* ElMessageBox and Settings

* Login formatting

* Rename VR overlay query

* Fix image popover and userdialog badges

* Formatting

* Big buttons

* Fixes, update Cef

* Fix gameLog table nav buttons jumping around while using nav buttons

* Fix z-index

* vr overlay

* vite input add theme

* defineAsyncComponent

* ISO 639-1

* fix i18n

* clean t

* Formatting, fix calendar, rotate arrows

* Show user status when user is offline

* Fix VR overlay

* fix theme and clean up

* split InstanceActivity

* tweak

* Fix VR overlay formatting

* fix scss var

* AppDebug hahahaha

* Years

* remove reactive

* improve perf

* state hah…

* fix user rendering poblems when user object is not yet loaded

* improve perf

* Update avatar/world image uploader, licenses, remove previous images dialog (old images are now deleted)

* improve perf 1

* Suppress stray errors

* fix traveling location display issue

* Fix empty instance creator

* improve friend list refresh performance

* fix main charts

* fix chart

* Fix darkmode

* Fix avatar dialog tags

---------

Co-authored-by: pa <maplenagisa@gmail.com>
2025-09-12 10:45:24 +12:00

394 lines
13 KiB
JavaScript

import Noty from 'noty';
import { ElMessageBox, ElMessage } from 'element-plus';
import { i18n } from '../plugin/i18n';
import { statusCodes } from '../shared/constants/api.js';
import { escapeTag } from '../shared/utils';
import {
useAuthStore,
useAvatarStore,
useNotificationStore,
useUpdateLoopStore,
useUserStore
} from '../stores';
import { AppDebug } from './appConfig.js';
import webApiService from './webapi.js';
import { watchState } from './watchState';
const pendingGetRequests = new Map();
export let failedGetRequests = new Map();
const t = i18n.global.t;
/**
* @template T
* @param {string} endpoint
* @param {RequestInit & { params?: any }} [options]
* @returns {Promise<T>}
*/
export function request(endpoint, options) {
const userStore = useUserStore();
const avatarStore = useAvatarStore();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const updateLoopStore = useUpdateLoopStore();
if (
!watchState.isLoggedIn &&
endpoint.startsWith('/auth') &&
endpoint !== 'config'
) {
throw `API request blocked while logged out: ${endpoint}`;
}
let req;
const init = {
url: `${AppDebug.endpointDomain}/${endpoint}`,
method: 'GET',
...options
};
const { params } = init;
if (init.method === 'GET') {
// don't retry recent 404/403
if (failedGetRequests.has(endpoint)) {
const lastRun = failedGetRequests.get(endpoint);
if (lastRun >= Date.now() - 900000) {
// 15mins
$throw(
-1,
t('api.error.message.403_404_bailing_request'),
endpoint
);
}
failedGetRequests.delete(endpoint);
}
// transform body to url
if (params === Object(params)) {
const url = new URL(init.url);
const { searchParams } = url;
for (const key in params) {
searchParams.set(key, params[key]);
}
init.url = url.toString();
}
// merge requests
req = pendingGetRequests.get(init.url);
if (typeof req !== 'undefined') {
if (req.time >= Date.now() - 10000) {
// 10s
return req.req;
}
pendingGetRequests.delete(init.url);
}
} else if (
init.uploadImage ||
init.uploadFilePUT ||
init.uploadImageLegacy
) {
// nothing
} else {
init.headers = {
'Content-Type': 'application/json;charset=utf-8',
...init.headers
};
init.body = params === Object(params) ? JSON.stringify(params) : '{}';
}
req = webApiService
.execute(init)
.catch((err) => {
$throw(0, err, endpoint);
})
.then((response) => {
if (
!watchState.isLoggedIn &&
endpoint.startsWith('/auth') &&
endpoint !== 'config'
) {
throw `API request blocked while logged out: ${endpoint}`;
}
if (!response.data) {
if (AppDebug.debugWebRequests) {
console.log(init, 'no data', response);
}
return response;
}
try {
response.data = JSON.parse(response.data);
if (AppDebug.debugWebRequests) {
console.log(init, 'parsed data', response.data);
}
return response;
} catch (e) {
console.error(e);
}
if (response.status === 200) {
$throw(
0,
t('api.error.message.invalid_json_response'),
endpoint
);
}
if (
response.status === 429 &&
init.url.endsWith('/instances/groups')
) {
updateLoopStore.nextGroupInstanceRefresh = 120; // 1min
$throw(429, t('api.status_code.429'), endpoint);
}
if (response.status === 504 || response.status === 502) {
// ignore expected API errors
$throw(response.status, response.data || '', endpoint);
}
$throw(
response.status,
response.data || response.statusText,
endpoint
);
})
.then(({ data, status }) => {
if (status === 200) {
if (!data) {
return data;
}
let text = '';
if (data.success === Object(data.success)) {
text = data.success.message;
} else if (data.OK === String(data.OK)) {
text = data.OK;
}
if (text) {
new Noty({
type: 'success',
text: escapeTag(text)
}).show();
}
return data;
}
if (status === 401) {
if (data.error?.message === '"Missing Credentials"') {
authStore.handleAutoLogin();
$throw(
401,
t('api.error.message.missing_credentials'),
endpoint
);
} else if (
data.error.message === '"Unauthorized"' &&
endpoint !== 'auth/user'
) {
// trigger 2FA dialog }
if (!authStore.twoFactorAuthDialogVisible) {
userStore.getCurrentUser();
}
$throw(401, t('api.status_code.401'), endpoint);
}
}
if (status === 403 && endpoint === 'config') {
ElMessageBox.alert(
t('api.error.message.vpn_in_use'),
`403 ${t('api.error.message.login_error')}`
);
authStore.handleLogoutEvent();
$throw(403, endpoint);
}
if (
init.method === 'GET' &&
status === 404 &&
endpoint.startsWith('avatars/')
) {
ElMessage({
message: t('message.api_handler.avatar_private_or_deleted'),
type: 'error'
});
avatarStore.avatarDialog.visible = false;
$throw(404, data.error?.message || '', endpoint);
}
if (status === 404 && endpoint.endsWith('/persist/exists')) {
return false;
}
if (
init.method === 'GET' &&
(status === 404 || status === 403) &&
!endpoint.startsWith('auth/user')
) {
failedGetRequests.set(endpoint, Date.now());
}
if (
init.method === 'GET' &&
status === 404 &&
endpoint.startsWith('users/') &&
endpoint.split('/').length - 1 === 1
) {
$throw(404, data.error?.message || '', endpoint);
}
if (
status === 404 &&
endpoint.startsWith('invite/') &&
init.inviteId
) {
notificationStore.expireNotification(init.inviteId);
}
if (status === 403 && endpoint.startsWith('invite/myself/to/')) {
$throw(403, data.error?.message || '', endpoint);
}
if (data && data.error === Object(data.error)) {
$throw(
data.error.status_code || status,
data.error.message,
endpoint
);
} else if (data && typeof data.error === 'string') {
$throw(data.status_code || status, data.error, endpoint);
}
$throw(status, data, endpoint);
});
if (init.method === 'GET') {
req.finally(() => {
pendingGetRequests.delete(init.url);
});
pendingGetRequests.set(init.url, {
req,
time: Date.now()
});
}
return req;
}
/**
* @param {number} code
* @param {string|object} [error]
* @param {string} [endpoint]
*/
export function $throw(code, error, endpoint) {
let message = [];
if (code > 0) {
const status = statusCodes[code];
if (typeof status === 'undefined') {
message.push(`${code}`);
} else {
const codeText = t(`api.status_code.${code}`);
message.push(`${code} ${codeText}`);
}
}
if (typeof error !== 'undefined') {
message.push(
`${t('api.error.message.error_message')}: ${typeof error === 'string' ? error : JSON.stringify(error)}`
);
}
if (typeof endpoint !== 'undefined') {
message.push(
`${t('api.error.message.endpoint')}: "${typeof endpoint === 'string' ? endpoint : JSON.stringify(endpoint)}"`
);
}
const text = message.map((s) => escapeTag(s)).join('<br>');
let ignoreError = false;
if (
(code === 404 || code === -1) &&
endpoint.split('/').length === 2 &&
(endpoint.startsWith('users/') ||
endpoint.startsWith('worlds/') ||
endpoint.startsWith('avatars/') ||
endpoint.startsWith('file/'))
) {
ignoreError = true;
}
if (endpoint.startsWith('analysis/')) {
ignoreError = true;
}
if (text.length && !ignoreError) {
if (AppDebug.errorNoty) {
AppDebug.errorNoty.close();
}
AppDebug.errorNoty = new Noty({
type: 'error',
text
});
AppDebug.errorNoty.show();
}
const e = new Error(text);
e.status = code;
e.endpoint = endpoint;
throw e;
}
/**
* Processes data in bulk by making paginated requests until all data is fetched or limits are reached.
*
* @async
* @function processBulk
* @param {object} options - Configuration options for bulk processing
* @param {function} options.fn - The function to call for each batch request. Must return a result with a 'json' property containing an array
* @param {object} [options.params={}] - Parameters to pass to the function. Will be modified to include pagination
* @param {number} [options.N=-1] - Maximum number of items to fetch. -1 for unlimited, 0 for fetch until page size not met
* @param {string} [options.limitParam='n'] - The parameter name used for page size in the request
* @param {function} [options.handle] - Callback function to handle each batch result
* @param {function} [options.done] - Callback function called when processing is complete. Receives boolean indicating success
* @returns {Promise<void>} Promise that resolves when bulk processing is complete
*
* @example
* await processBulk({
* fn: fetchUsers,
* params: { n: 50 },
* N: 200,
* handle: (result) => console.log(`Fetched ${result.json.length} users`),
* done: (success) => console.log(success ? 'Complete' : 'Failed')
* });
*/
export async function processBulk(options) {
const {
fn,
params: rawParams = {},
N = -1,
limitParam = 'n',
handle,
done
} = options;
if (typeof fn !== 'function') {
return;
}
const params = { ...rawParams };
if (typeof params.offset !== 'number') {
params.offset = 0;
}
const pageSize = params[limitParam];
let totalFetched = 0;
try {
while (true) {
const result = await fn(params);
const batchSize = result.json.length;
if (typeof handle === 'function') {
handle(result);
}
if (batchSize === 0) {
break;
}
if (N > 0) {
totalFetched += batchSize;
if (totalFetched >= N) {
break;
}
} else if (N === 0) {
if (batchSize < pageSize) {
break;
}
totalFetched += batchSize;
} else {
totalFetched += batchSize;
}
params.offset += batchSize;
}
if (typeof done === 'function') {
done(true);
}
} catch (err) {
console.error('Bulk processing error:', err);
if (typeof done === 'function') {
done(false);
}
}
}