bugfixing

This commit is contained in:
Michal Kváček
2026-09-14 11:08:22 +02:00
parent f7845b963d
commit ae7072ddee
8 changed files with 167 additions and 77 deletions
+6
View File
@@ -3,3 +3,9 @@ apps:
script: /app/index.js
exec_mode: cluster
instances: 3
# Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru
# přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na
# celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho
# SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz
# handoffs/05-pm2-docker-memory-limits.md.
max_memory_restart: '400M'
+6 -1
View File
@@ -28,7 +28,12 @@ module.exports = configure(function (ctx) {
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files
boot: [
"sentry",
// client-only: Sentry browser SDK (browserTracingIntegration,
// replayIntegration) is DOM-only and has no business running per SSR
// request - server: false means Quasar excludes this boot file from
// the server bundle entirely (not just a runtime guard).
// See handoffs/04-sentry-ssr-guard.md.
{path: "sentry", server: false},
"cookies",
"axios",
"apollo",
+109 -64
View File
@@ -1,5 +1,6 @@
import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
import {ErrorLink} from "@apollo/client/link/error";
import {defineStore} from "pinia";
import {useAppStore} from "stores/App";
import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog";
@@ -29,76 +30,120 @@ const handleRefreshToken = (store, router, forward, operation) => {
}))
}
export const apolloWrapper = {};
export default (({store, router}) => {
const uploadLink = new UploadHttpLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
// SSR: boot() runs again for every request. The client/query/mutate below
// used to live on a shared module-level `apolloWrapper` object that every
// request's boot() reassigned in place - under concurrent SSR requests, one
// request could end up calling into another request's (still-being-set-up
// or already-overwritten) apolloClient/store, i.e. querying/mutating with
// the wrong user's auth context. See
// handoffs/02-apollo-wrapper-race-condition.md for the full analysis.
//
// Fix: make this a Pinia store instead of a plain shared object. `init()`
// is called from boot() with the request's own `store`/`router` explicitly
// (same proven pattern as useAuthStore(store) elsewhere in this file), so
// the closures below always close over the correct request. `query`/
// `mutate` are then read back through Pinia's own SSR-safe per-request
// resolution (useApolloStore() with no arg, called from preFetch/
// onServerPrefetch-triggered store actions - the same mechanism every
// other useXStore() call in this codebase already relies on), instead of
// through a shared mutable object.
export const useApolloStore = defineStore('Apollo', () => {
let client = null
let queryImpl = null
let mutateImpl = null
const authLink = new ApolloLink((operation, forward) => {
const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const init = ({store, router}) => {
if (client) return // boot() already initialized this request's store
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
const uploadLink = new UploadHttpLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
const authLink = new ApolloLink((operation, forward) => {
const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
}
}
}
}
})
const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]),
cache: new InMemoryCache(),
connectToDevTools: true,
ssrMode: true,
});
apolloWrapper.query = (query, variables = {}) => {
return apolloClient.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables
})
.then((response) => response)
.catch(err => {
const errStr = err.toString();
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
useAppStore(store).appStatus = 404;
return Promise.resolve();
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err)
}
return Promise.reject(err);
})
};
apolloWrapper.mutate = (mutation, variables) => {
return apolloClient
.mutate({
mutation: mutation,
client = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]),
cache: new InMemoryCache(),
connectToDevTools: true,
ssrMode: true,
});
queryImpl = (query, variables = {}) => {
return client.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables,
variables: variables
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
.then((response) => response)
.catch(err => {
const errStr = err.toString();
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
useAppStore(store).appStatus = 404;
return Promise.resolve();
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err)
}
return Promise.reject(err);
})
};
mutateImpl = (mutation, variables) => {
return client
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache',
variables: variables,
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
}
const query = (query, variables) => queryImpl(query, variables)
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
return {init, query, mutate}
})
// Backward-compatible shape so existing call sites
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
// now delegating to the request-scoped store above instead of a shared
// mutable singleton.
export const apolloWrapper = {
query: (...args) => useApolloStore().query(...args),
mutate: (...args) => useApolloStore().mutate(...args),
}
export default (({store, router}) => {
useApolloStore(store).init({store, router})
});
+28 -5
View File
@@ -1,9 +1,32 @@
import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers";
import {defineStore} from 'pinia'
let cookies = Cookies;
export default boot(({ssrContext}) => {
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
// SSR: boot() runs again for every request. `cookies` used to be a single
// shared module-level variable reassigned in place by every request's
// boot() - under concurrent SSR requests, one request's
// Cookies.parseSSR(ssrContext) result (which carries that request's own
// accessToken) could overwrite another still-in-flight request's
// `cookies` before it finished reading it, leaking one user's access
// token to another. See handoffs/03-cookies-shared-state.md.
//
// Fix: make this a Pinia store, initialized explicitly per request from
// boot() with that request's own `store` (same proven pattern as
// boot/apollo.js), instead of a shared mutable variable.
export const useCookiesStore = defineStore('Cookies', () => {
let instance = null
const init = (ssrContext) => {
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
}
const get = (...args) => instance.get(...args)
const set = (...args) => instance.set(...args)
const remove = (...args) => instance.remove(...args)
return {init, get, set, remove}
})
export default boot(({store, ssrContext}) => {
useCookiesStore(store).init(ssrContext)
});
export {cookies}
+5
View File
@@ -1,7 +1,12 @@
import {boot} from "quasar/wrappers";
import * as Sentry from "@sentry/browser";
// Client-only boot file (see quasar.config.js: {path: "sentry", server:
// false} - excludes this from the server bundle entirely). The guard below
// is a defensive second layer in case that config entry is ever changed
// back to a plain string. See handoffs/04-sentry-ssr-guard.md.
export default boot(({app, router}) => {
if (process.env.SERVER) return;
if (process.env.NODE_ENV !== 'production') return;
Sentry.init({
+13 -5
View File
@@ -1,9 +1,17 @@
import {defineStore} from 'pinia'
import {defineStore, getActivePinia} from 'pinia'
import {api} from "boot/axios";
import {cookies} from "boot/cookies";
import {useCookiesStore} from "boot/cookies";
import {useFlightsStore} from "stores/Flights";
export const useAuthStore = defineStore('Auth', () => {
// Captured synchronously at setup time (see handoffs/03-cookies-shared-state.md):
// getActivePinia() here always resolves to the same pinia instance this
// store was created for (Pinia sets it up right before running setup()),
// so closing over it now and passing it explicitly to useCookiesStore()
// later keeps cookie access correctly scoped to this request even if
// other concurrent SSR requests are being set up in between.
const pinia = getActivePinia();
const login = async (formData) => {
return await api.post("/login", formData).then(({data}) => {
setTokens(data);
@@ -18,18 +26,18 @@ export const useAuthStore = defineStore('Auth', () => {
};
const unsetTokens = () => {
cookies.remove("accessToken")
useCookiesStore(pinia).remove("accessToken")
};
const setTokens = ({access_token, access_token_validity}) => {
cookies.set("accessToken", access_token, {
useCookiesStore(pinia).set("accessToken", access_token, {
expires: `${access_token_validity}s`,
sameSite: 'Strict'
});
};
const getAccessToken = () => {
return cookies.get("accessToken");
return useCookiesStore(pinia).get("accessToken");
};
const hasAccessToken = () => !!getAccessToken();
-1
View File
@@ -4,7 +4,6 @@ import {EDIT_USER} from "src/graphql/mutations/user";
import {LOGGED_USER_DETAIL, PILOT_DETAIL} from "src/graphql/queries/user";
import {ref} from "vue";
import {mutate, query} from "stores/helpers";
import {cookies} from "boot/cookies";
export const useLoggedUserStore = defineStore('LoggedUser', () => {
const user = ref(null);
-1
View File
@@ -4,7 +4,6 @@ import {EDIT_USER} from "src/graphql/mutations/user";
import {LOGGED_USER_DETAIL, PILOT_DETAIL} from "src/graphql/queries/user";
import {ref} from "vue";
import {mutate, query} from "stores/helpers";
import {cookies} from "boot/cookies";
export const usePilotsStore = defineStore('Pilots', () => {
const pilot = ref(null);