feat(fe2): parallel middlewares (#5314)

* parallel middlewares foundation + hydration mismatch

* moved to fully parallel middlewares

* a bit less hacky

* some more cleanup

* improved nuxt 4 error formatting

* make parallel middlewares toggleable
This commit is contained in:
Kristaps Fabians Geikins
2025-08-27 12:38:04 +03:00
committed by GitHub
parent 074b554faf
commit 843606775c
31 changed files with 142 additions and 44 deletions
@@ -88,12 +88,12 @@ export default defineNuxtPlugin(() => {
```typescript
// middleware/auth.ts (route middleware)
export default defineNuxtRouteMiddleware((to, from) => {
export default defineParallelizedNuxtRouteMiddleware((to, from) => {
// Route protection logic
})
// middleware/global.global.ts (global middleware)
export default defineNuxtRouteMiddleware((to, from) => {
export default defineParallelizedNuxtRouteMiddleware((to, from) => {
// Runs on every route change
})
```
@@ -14,9 +14,10 @@
<div
v-if="isDev && error.stack"
class="max-w-xl text-body-xs text-foreground-2"
v-html="error.stack"
/>
class="whitespace-pre-line font-mono max-w-xl text-body-xs text-foreground-2"
>
{{ error.stack.trim() }}
</div>
<FormButton :to="homeRoute">Go home</FormButton>
</div>
</template>
@@ -4,6 +4,7 @@
<LayoutMenu
v-model:open="showActionsMenu"
:items="actionsItems"
:menu-id="menuId"
:menu-position="HorizontalDirection.Left"
@click.stop.prevent
@chosen="onActionChosen"
@@ -43,6 +44,7 @@ const props = defineProps<{
const { copy } = useClipboard()
const copyModelLink = useCopyModelLink()
const menuId = useId()
const disabledMessage = computed(
() =>
@@ -0,0 +1,68 @@
import type { RouteMiddleware } from '#app'
import type { NavigationGuardReturn } from '#vue-router'
import { isUndefinedOrVoid } from '@speckle/shared'
import { useScopedState } from '~/lib/common/composables/scopedState'
const useMiddlewareParallelizationState = () =>
useScopedState('middleware_parallelization', () => ({
middlewares: [] as Array<() => Promise<NavigationGuardReturn>>
}))
/**
* Make the middleware process in parallel with other middlewares and only get fully awaited at the end
*/
const withParallelization = (middleware: RouteMiddleware): RouteMiddleware => {
return (...args) => {
const app = useNuxtApp()
const {
public: { parallelMiddlewares }
} = useRuntimeConfig()
if (!parallelMiddlewares) {
return middleware(...args)
}
const { middlewares } = useMiddlewareParallelizationState()
middlewares.push(async () => app.runWithContext(() => middleware(...args)))
}
}
/**
* defineParallelizedNuxtRouteMiddleware() w/ parallelization support
*/
export const defineParallelizedNuxtRouteMiddleware = (
middleware: RouteMiddleware
): RouteMiddleware => {
return withParallelization(middleware)
}
export const useFinalizeParallelMiddlewares = () => {
const state = useMiddlewareParallelizationState()
const logger = useLogger()
return {
finalize: async () => {
const middlewares = state.middlewares
if (!middlewares.length) {
return
}
logger.debug('Finalizing {count} parallel middlewares', {
count: middlewares.length,
middlewares
})
try {
const results = await Promise.all(middlewares.map((m) => m()))
// Report results
for (const resultItem of results) {
if (!isUndefinedOrVoid(resultItem) && resultItem !== true) {
return resultItem
}
}
} finally {
state.middlewares.length = 0
}
}
}
}
@@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware(() => {
export default defineParallelizedNuxtRouteMiddleware(() => {
// Add response header that shows this is a FE2 request
const { ssrContext } = useNuxtApp()
if (ssrContext) {
@@ -67,7 +67,7 @@ const adminPageRgx = /^\/admin\/?/
* Setting up all kinds of redirects (e.g. for FE1 backwards compatibility)
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const logger = useLogger()
const path = to.path
const apollo = useApolloClientFromNuxt()
@@ -23,7 +23,7 @@ const autoAcceptableWorkspaceInviteQuery = graphql(`
/**
* Handles all of the invite auto-accepting logic (when clicking on email accept links)
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const shouldTryProjectAccept = to.path.startsWith('/projects/')
@@ -19,16 +19,21 @@ import { convertThrowIntoFetchResult } from '~~/lib/common/helpers/graphql'
import { buildActiveUserWorkspaceExistenceCheckQuery } from '~/lib/workspaces/helpers/middleware'
import { useMiddlewareQueryFetchPolicy } from '~/lib/core/composables/navigation'
export default defineNuxtRouteMiddleware(async (to, from) => {
export default defineParallelizedNuxtRouteMiddleware(async (to, from) => {
const isAuthPage = to.path.startsWith('/authn/')
const isSSOPath = to.path.includes('/sso/')
if (isAuthPage || isSSOPath) return
const client = useApolloClientFromNuxt()
const fetchPolicy = useMiddlewareQueryFetchPolicy()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
// Fetch required data
const [{ data: serverInfoData }, { data: userData }] = await Promise.all([
const [
{ data: serverInfoData },
{ data: userData },
{ data: workspaceExistenceData }
] = await Promise.all([
client
.query({
query: mainServerInfoDataQuery
@@ -39,7 +44,17 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
query: activeUserQuery,
fetchPolicy: fetchPolicy(to, from)
})
.catch(convertThrowIntoFetchResult)
.catch(convertThrowIntoFetchResult),
...(isWorkspacesEnabled.value
? [
client
.query({
...buildActiveUserWorkspaceExistenceCheckQuery(),
fetchPolicy: fetchPolicy(to, from)
})
.catch(convertThrowIntoFetchResult)
]
: [{ data: undefined }])
])
// If user is not logged in, skip all checks
@@ -81,17 +96,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
// 3. Workspace join/create redirect
// Everything past this point is only relevant for workspace enabled instances
const isWorkspacesEnabled = useIsWorkspacesEnabled()
if (!isWorkspacesEnabled.value) return
const { data: workspaceExistenceData } = await client
.query({
...buildActiveUserWorkspaceExistenceCheckQuery(),
fetchPolicy: fetchPolicy(to, from)
})
.catch(convertThrowIntoFetchResult)
const workspaces = workspaceExistenceData?.activeUser?.workspaces?.items ?? []
const hasWorkspaces = workspaces.length > 0
const hasDiscoverableWorkspaces =
@@ -0,0 +1,9 @@
import { useFinalizeParallelMiddlewares } from '~/lib/core/helpers/middleware'
/**
* Should be the very last middleware that's run on ALL pages and navigations
*/
export default defineNuxtRouteMiddleware(async () => {
const { finalize } = useFinalizeParallelMiddlewares()
return await finalize()
})
+1 -1
View File
@@ -6,7 +6,7 @@ import { Roles } from '@speckle/shared'
/**
* Apply this to a page to prevent access by non-admin users
*/
export default defineNuxtRouteMiddleware(async () => {
export default defineParallelizedNuxtRouteMiddleware(async () => {
const client = useApolloClientFromNuxt()
const { data } = await client
+1 -1
View File
@@ -7,7 +7,7 @@ import { loginRoute } from '~~/lib/common/helpers/route'
/**
* Apply this to a page to prevent unauthenticated access
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const nuxt = useNuxtApp()
const client = useApolloClientFromNuxt()
const postAuthRedirect = usePostAuthRedirect({ route: to })
@@ -19,7 +19,7 @@ const canViewProjectTokensQuery = graphql(`
/**
* Apply this to a page to prevent unauthenticated access to tokens and ensure the user is the owner
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const client = useApolloClientFromNuxt()
const projectId = to.params.id as string
@@ -19,7 +19,7 @@ const canViewProjectSettingsQuery = graphql(`
/**
* Apply this to a page to prevent unauthenticated access to settings ensuring the user is a collaborator
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const client = useApolloClientFromNuxt()
// Fetch project role data to check if the user is a collaborator
@@ -19,7 +19,7 @@ const canViewProjectWebhooksQuery = graphql(`
/**
* Apply this to a page to prevent unauthenticated access to webhooks and ensure the user is the owner
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const client = useApolloClientFromNuxt()
// Fetch project role data to check if the user is the owner
@@ -2,7 +2,7 @@ import { projectsRoute, workspaceRoute } from '~/lib/common/helpers/route'
import { useApolloClientFromNuxt } from '~~/lib/common/composables/graphql'
import { activeUserActiveWorkspaceCheckQuery } from '~/lib/auth/graphql/queries'
export default defineNuxtRouteMiddleware(async () => {
export default defineParallelizedNuxtRouteMiddleware(async () => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const client = useApolloClientFromNuxt()
+1 -1
View File
@@ -8,7 +8,7 @@ const exclusionList = ['authorize-app', 'reset-password']
/**
* Apply this to a page to prevent authenticated access
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const nuxt = useNuxtApp()
const client = useApolloClientFromNuxt()
@@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware((to) => {
export default defineParallelizedNuxtRouteMiddleware((to) => {
const { ssrContext } = useNuxtApp()
if (ssrContext) {
@@ -3,7 +3,7 @@ import { useSetActiveWorkspace } from '~/lib/user/composables/activeWorkspace'
/**
* Clear active workspace when navigating to the projects page
*/
export default defineNuxtRouteMiddleware(async () => {
export default defineParallelizedNuxtRouteMiddleware(async () => {
const { setActiveWorkspace } = useSetActiveWorkspace()
const { isLoggedIn } = useActiveUser()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
@@ -6,7 +6,7 @@ import { homeRoute, workspaceCreateRoute } from '~~/lib/common/helpers/route'
/**
* Redirect user to /workspaces/actions/create, if they have no discoverable workspaces
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
if (!isWorkspacesEnabled.value) return
@@ -5,7 +5,7 @@ import { useWorkspacePublicSsoCheck } from '~/lib/workspaces/composables/sso'
/**
* Used to validate that the workspace has SSO enabled, redirects to workspace page if not
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
// Skip middleware when handling SSO callback with access code.
// This page serves as both the SSO login page and OAuth callback URL.
// We need to let the callback through to process the access code before any redirects.
@@ -7,7 +7,7 @@ import {
} from '~/lib/common/helpers/graphql'
import { projectAutomationAccessCheckQuery } from '~/lib/projects/graphql/queries'
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const projectId = to.params.id as string
// const automationId = to.params.aid as string
@@ -5,7 +5,7 @@ import {
getFirstErrorMessage
} from '~/lib/common/helpers/graphql'
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const functionId = to.params.fid as string
const isAutomateEnabled = useIsAutomateModuleEnabled()
@@ -1,3 +1,4 @@
import { useMiddlewareQueryFetchPolicy } from '~/lib/core/composables/navigation'
import {
castToSupportedVisibility,
SupportedProjectVisibility
@@ -13,16 +14,18 @@ import { projectModelCheckQuery } from '~~/lib/projects/graphql/queries'
/**
* Used in project page to validate that project ID refers to a valid project and redirects to 404 if not
*/
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to, from) => {
const projectId = to.params.id as string
const modelId = to.params.modelId as string
const client = useApolloClientFromNuxt()
const fetchPolicy = useMiddlewareQueryFetchPolicy()
const { data, errors } = await client
.query({
query: projectModelCheckQuery,
variables: { projectId, modelId }
variables: { projectId, modelId },
fetchPolicy: fetchPolicy(to, from)
})
.catch(convertThrowIntoFetchResult)
@@ -13,7 +13,7 @@ import { useMiddlewareQueryFetchPolicy } from '~/lib/core/composables/navigation
/**
* Used in project page to validate that project ID refers to a valid project and redirects to 404 if not
*/
export default defineNuxtRouteMiddleware(async (to, from) => {
export default defineParallelizedNuxtRouteMiddleware(async (to, from) => {
const projectId = to.params.id as string
// Check if embed token is present in URL
@@ -10,7 +10,7 @@ import { useMiddlewareQueryFetchPolicy } from '~/lib/core/composables/navigation
/**
* Used to validate that the workspace ID refers to a valid workspace and redirects to 404 if not
*/
export default defineNuxtRouteMiddleware(async (to, from) => {
export default defineParallelizedNuxtRouteMiddleware(async (to, from) => {
const workspaceSlug = to.params.slug as string
const client = useApolloClientFromNuxt()
@@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware(() => {
export default defineParallelizedNuxtRouteMiddleware(() => {
const isAutomateEnabled = useIsAutomateModuleEnabled()
if (!isAutomateEnabled.value) {
return abortNavigation(
@@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware(() => {
export default defineParallelizedNuxtRouteMiddleware(() => {
const isWorkspacesEnabled = useIsWorkspacesEnabled()
// If workspaces are enabled, continue as normal
+1 -1
View File
@@ -1,6 +1,6 @@
import { useSettingsMenuState } from '~/lib/settings/composables/menu'
export default defineNuxtRouteMiddleware((to, from) => {
export default defineParallelizedNuxtRouteMiddleware((to, from) => {
const settingsMenuState = useSettingsMenuState()
if (to.path.startsWith('/settings') && !from.path.startsWith('/settings')) {
+1 -1
View File
@@ -14,7 +14,7 @@ const resolveLinkQuery = graphql(`
}
`)
export default defineNuxtRouteMiddleware(async (to) => {
export default defineParallelizedNuxtRouteMiddleware(async (to) => {
const client = useApolloClientFromNuxt()
const threadId = to.params.threadId as string
const projectId = to.params.id as string
+9 -3
View File
@@ -83,12 +83,14 @@ export default defineNuxtConfig({
datadogSite: '',
datadogService: '',
datadogEnv: '',
intercomAppId: ''
intercomAppId: '',
parallelMiddlewares: true
}
},
experimental: {
emitRouteChunkError: 'automatic-immediate'
emitRouteChunkError: 'automatic-immediate',
asyncContext: true // necessary for parallel middlewares
},
alias: {
@@ -171,7 +173,11 @@ export default defineNuxtConfig({
headers: {
// No search engine indexing on any of the pages anywhere! TODO: Come up with a more appropriate policy
'X-Robots-Tag': 'noindex, nofollow, noarchive'
}
},
appMiddleware: [
// Has to be applied to all pages and as the very last app middleware (hence the 999 prefix)
'999-parallel-finalize'
]
},
'/functions': {
redirect: {
+3 -1
View File
@@ -14,6 +14,7 @@ import {
} from '~/lib/common/helpers/graphql'
import { checkIfIsInPlaceNavigation } from '~/lib/common/helpers/navigation'
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
import { defineParallelizedNuxtRouteMiddleware } from '~/lib/core/helpers/middleware'
/**
* Debugging helper to ensure variables are available in debugging scope
@@ -40,5 +41,6 @@ export {
ROOT_QUERY,
ROOT_MUTATION,
ROOT_SUBSCRIPTION,
ViewerEventBusKeys
ViewerEventBusKeys,
defineParallelizedNuxtRouteMiddleware
}