chore(fe2): upgrade to nuxt 4 (#5306)

* actual npm update

* migrating plugin

* fix hydration (todo redis)

* fix dashboard title

* linting fixes

* fix ssr dev logs

* fix shared build

* more linting fixes

* more lint fixes

* preview dockerfile fix

* fix max stack trace issue
This commit is contained in:
Kristaps Fabians Geikins
2025-08-27 10:26:32 +03:00
committed by GitHub
parent c353966993
commit 9d9a456b28
25 changed files with 4324 additions and 13849 deletions
@@ -58,7 +58,7 @@ const { validateCheckoutSession } = useBillingActions()
const { finalizeWizard } = useWorkspacesWizard()
const workspaceTitle = ref<string>('')
useHeadSafe({
useHead({
title: workspaceTitle
})
const hasFinalized = ref(false)
@@ -1,9 +1,12 @@
import { Redis } from 'ioredis'
import type pino from 'pino'
export const createRedis = async (params: { logger: pino.Logger }) => {
const { logger } = params
// invoke composables sync first
const { redisUrl } = useRuntimeConfig()
// doesnt work as a static import for some reason, maybe client build is picking it up
const { default: Redis } = await import('ioredis')
const { logger } = params
if (!redisUrl?.length) {
return undefined
}
@@ -1,8 +1,7 @@
import path from 'path'
import type { ApolloClientOptions } from '@apollo/client/core'
import { addPluginTemplate, defineNuxtModule } from '@nuxt/kit'
import type { MaybeAsync } from '@speckle/shared'
import type { NuxtApp } from '#app'
import { defineNuxtModule, addTemplate, createResolver, addPlugin } from 'nuxt/kit'
/**
* Config resolver default exported function expected type
@@ -27,20 +26,32 @@ export default defineNuxtModule<ApolloModuleOptions>({
name: 'apollo-module',
configKey: 'apollo',
compatibility: {
nuxt: '>= 3.0.0 || 3.0.0-rc.13'
nuxt: '>= 3.0.0 || 3.0.0-rc.13 || >= 4.0.0'
}
},
hooks: {},
setup(moduleOptions) {
const resolver = createResolver(import.meta.url)
if (!moduleOptions.configResolvers?.default) {
throw new Error('No apollo client config resolvers registered!')
}
addPluginTemplate({
src: path.resolve(__dirname, './templates/plugin.js'),
options: {
configResolvers: moduleOptions.configResolvers
}
const imports = Object.entries(moduleOptions.configResolvers)
.map(([key, path]) => `import ${key}Resolver from '${path}'`)
.join('\n')
const resolverMap = `const resolvers = {
${Object.keys(moduleOptions.configResolvers)
.map((key) => `${key}: ${key}Resolver`)
.join(',\n')}
}`
const templateContents = `${imports}\n${resolverMap}\nexport default resolvers`
addTemplate({
filename: 'apollo-config-resolvers.mjs',
getContents: () => templateContents
})
addPlugin(resolver.resolve('./templates/plugin'))
}
})
@@ -1,81 +0,0 @@
<% for (const [key, path] of Object.entries(options.configResolvers)) { %>
import <%= key %>ConfigResolver from '<%= path %>';
<% } %>
import { ApolloClient } from '@apollo/client/core'
import { defineNuxtPlugin } from '#app'
import { ApolloClients, provideApolloClient } from '@vue/apollo-composable'
import {markRaw, toRaw} from 'vue'
export default defineNuxtPlugin(async (nuxt) => {
// in dev mode, load better messages
if (import.meta.dev) {
const devSettings = await import('@apollo/client/dev')
devSettings.loadDevMessages()
}
// Load all configs
const keyedConfigs = {};
<% for (const key of Object.keys(options.configResolvers)) { %>
keyedConfigs['<%= key %>'] = await Promise.resolve(<%= key %>ConfigResolver(nuxt));
<% } %>
if (!keyedConfigs.default) {
throw new Error("Couldn't successfully resolve config for default config!")
}
if (process.client) {
// Restore cached data from SSR
for (const [key, config] of Object.entries(keyedConfigs)) {
/** @type {import('@apollo/client').InMemoryCache} */
const cache = config.cache;
const restorable = window.__NUXT__?.apollo?.[key] || null
if (restorable) {
// Cache is proxified by Vue, gotta undo all that or all hell breaks loose
cache.restore(markRaw(toRaw(restorable)));
config.cache = cache;
}
}
}
// Init clients
let defaultClient,
keyedClients = {};
for (const [key, config] of Object.entries(keyedConfigs)) {
const client = new ApolloClient({
...config,
...(process.server ? {ssrMode: true} : {ssrForceFetchDelay: 100}),
connectToDevTools: !!process.dev
});
if (key === 'default') {
defaultClient = client;
if (process.client && process.dev) {
window.__APOLLO_CLIENT__ = client;
}
} else {
keyedClients[key] = client;
}
}
// Make sure server side serializes state on render
if (process.server) {
const ApolloSSR = await import('@vue/apollo-ssr')
nuxt.hook('app:rendered', () => {
nuxt.ssrContext.payload.apollo = ApolloSSR.getStates({
default: defaultClient,
...keyedClients
})
});
}
// For composable api
const providedClients = {
default: defaultClient,
...keyedClients
};
nuxt.vueApp.provide(ApolloClients, providedClients)
// For global access through $apollo
nuxt.provide("apollo", providedClients)
});
@@ -0,0 +1,77 @@
import configResolvers from '#build/apollo-config-resolvers.mjs'
import { ApolloClient, type ApolloClientOptions } from '@apollo/client/core'
import { defineNuxtPlugin } from '#app'
import { ApolloClients } from '@vue/apollo-composable'
import { markRaw, toRaw } from 'vue'
export default defineNuxtPlugin(async (nuxt) => {
// in dev mode, load better messages
if (import.meta.dev) {
const devSettings = await import('@apollo/client/dev')
devSettings.loadDevMessages()
}
// Load all configs
const keyedConfigs: Record<string, ApolloClientOptions<unknown>> = {}
for (const key of Object.keys(configResolvers)) {
keyedConfigs[key] = await Promise.resolve(configResolvers[key](nuxt))
}
if (!keyedConfigs.default) {
throw new Error("Couldn't successfully resolve config for default config!")
}
if (import.meta.client) {
// Restore cached data from SSR
for (const [key, config] of Object.entries(keyedConfigs)) {
const cache = config.cache
const restorable = nuxt.payload.apollo?.[key] || null
if (restorable) {
// Cache is proxified by Vue, gotta undo all that or all hell breaks loose
cache.restore(markRaw(toRaw(restorable)))
config.cache = cache
}
}
}
// Init clients
let defaultClient: ApolloClient<unknown>,
keyedClients: Record<string, ApolloClient<unknown>> = {}
for (const [key, config] of Object.entries(keyedConfigs)) {
const client = new ApolloClient({
...config,
...(import.meta.server ? { ssrMode: true } : { ssrForceFetchDelay: 100 }),
connectToDevTools: !!import.meta.dev
})
if (key === 'default') {
defaultClient = client
if (import.meta.client && import.meta.dev) {
window.__APOLLO_CLIENT__ = client
}
} else {
keyedClients[key] = client
}
}
// Make sure server side serializes state on render
if (import.meta.server) {
const ApolloSSR = await import('@vue/apollo-ssr')
nuxt.hook('app:rendered', () => {
nuxt.ssrContext!.payload.apollo = ApolloSSR.getStates({
default: defaultClient,
...keyedClients
})
})
}
// For composable api
const providedClients: Record<string, ApolloClient<unknown>> = {
default: defaultClient!,
...keyedClients
}
nuxt.vueApp.provide(ApolloClients, providedClients)
// For global access through $apollo
nuxt.provide('apollo', providedClients)
})
@@ -115,7 +115,7 @@ export function useStateSerialization() {
position: state.ui.camera.position.value.toArray(),
target: state.ui.camera.target.value.toArray(),
isOrthoProjection: state.ui.camera.isOrthoProjection.value,
zoom: (get(camControls, '_zoom') as number) || 1 // kinda hacky, _zoom is a protected prop
zoom: (get(camControls, '_zoom') as unknown as number) || 1 // kinda hacky, _zoom is a protected prop
},
viewMode: state.ui.viewMode.value,
sectionBox: state.ui.sectionBox.value ? box : null,
+19 -8
View File
@@ -1,10 +1,11 @@
import { join } from 'path'
import { withoutLeadingSlash } from 'ufo'
import { sanitizeFilePath } from 'mlly'
import { filename } from 'pathe/utils'
import * as Environment from '@speckle/shared/environment'
import { defineNuxtConfig } from 'nuxt/config'
// Copied out from nuxt vite-builder source to correctly build output chunk/entry/asset/etc file names
const withoutLeadingSlash = (path: string) => path.replace(/^\//, '')
const buildOutputFileName = (chunkName: string) =>
withoutLeadingSlash(
join('/_nuxt/', `${sanitizeFilePath(filename(chunkName))}.[hash].js`)
@@ -26,6 +27,8 @@ const hydrationMismatchReportingEnabled = ['1', 'true', true, 1].includes(
HYDRATION_MISMATCH_REPORTING
)
const external = ['ioredis', 'crypto', 'jsdom']
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
...(buildSourceMaps ? { sourcemap: true } : {}),
@@ -35,7 +38,9 @@ export default defineNuxtConfig({
strict: true,
tsConfig: {
compilerOptions: {
moduleResolution: 'bundler'
moduleResolution: 'bundler',
// TODO: More correct, but requires a lot of (minor) changes
noUncheckedIndexedAccess: false
}
}
},
@@ -101,9 +106,13 @@ export default defineNuxtConfig({
: {})
},
ssr: {
external
},
optimizeDeps: {
// Should only be ran on serverside anyway. W/o this it tries to transpile it unsuccessfully
exclude: ['jsdom']
exclude: external
},
vue: {
@@ -148,7 +157,7 @@ export default defineNuxtConfig({
}
},
// Leave imports as is, they're server-side only
external: ['jsdom']
external: ['jsdom', 'crypto']
}
// // optionally disable minification for debugging
// minify: false,
@@ -239,14 +248,14 @@ export default defineNuxtConfig({
to: '/workspaces/actions/create',
statusCode: 301
}
},
'/projects/:id/models/:modelId': {
ssr: true // TODO: Should experiment w/ false, but this breaks SSR script injection like for RUM
}
},
nitro: {
compressPublicAssets: true
compressPublicAssets: true,
externals: {
external
}
},
build: {
@@ -270,6 +279,8 @@ export default defineNuxtConfig({
'graphql/utilities/getOperationAST'
]
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prometheus: {
verbose: false
},
+4 -4
View File
@@ -12,7 +12,7 @@
"dev:app": "concurrently \"nuxt dev\" \"yarn gqlgen:watch\"",
"dev": "yarn dev:app",
"preview": "nuxt preview",
"analyze": "nuxt analyze",
"analyze": "NODE_OPTIONS=--max-old-space-size=8192 nuxt analyze",
"lint:js": "eslint .",
"lint:tsc": "vue-tsc --noEmit",
"lint:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --check .",
@@ -26,7 +26,7 @@
},
"dependencies": {
"@apollo/client": "^3.13.8",
"@artmizu/nuxt-prometheus": "^2.2.1",
"@artmizu/nuxt-prometheus": "^2.5.2",
"@datadog/browser-rum": "^5.11.0",
"@headlessui/vue": "npm:@speckle/headlessui-vue@1.7.23-alpha.0",
"@heroicons/vue": "^2.0.12",
@@ -63,7 +63,7 @@
"dayjs": "^1.11.7",
"dompurify": "^3.0.4",
"graphql": "^16.6.0",
"ioredis": "^5.3.2",
"ioredis": "^5.7.0",
"js-cookie": "^3.0.1",
"jsdom": "^22.1.0",
"lodash-es": "^4.17.21",
@@ -126,7 +126,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vuejs-accessibility": "^2.3.0",
"mixpanel": "^0.18.0",
"nuxt": "^3.15.0",
"nuxt": "^4.0.3",
"pino-pretty": "^10.0.1",
"postcss": "^8.4.31",
"postcss-custom-properties": "^12.1.9",
+5 -31
View File
@@ -1,7 +1,7 @@
import { collectLongTrace } from '@speckle/shared'
import type { LogType } from 'consola'
import dayjs from 'dayjs'
import { get, omit } from 'lodash-es'
import { omit } from 'lodash-es'
import type { SetRequired } from 'type-fest'
import { useReadUserId } from '~/lib/auth/composables/activeUser'
import {
@@ -25,25 +25,6 @@ import {
const simpleStripHtml = (str: string) => str.replace(/<[^>]*>?/gm, '')
// i dunno why but importing this returns undefined in server build, it makes no sense to me why it would be stripped out
// but the solution is to duplicate this here
const consolaLogLevels = {
silent: Number.NEGATIVE_INFINITY,
fatal: 0,
error: 0,
warn: 1,
log: 2,
info: 3,
success: 3,
fail: 3,
ready: 3,
start: 3,
box: 3,
debug: 4,
trace: 5,
verbose: Number.POSITIVE_INFINITY
}
/**
* - Setting up Pino logger in SSR, basic console.log fallback in CSR
* - Also sets up ability to add extra transport for other observability tools
@@ -100,7 +81,8 @@ export default defineNuxtPlugin(async (nuxtApp) => {
buildLogger,
enableDynamicBindings,
serializeRequest,
prettifiedLoggerFactory
prettifiedLoggerFactory,
initSsrDevLogs
} = await import('~/server/lib/core/helpers/observability')
logger = enableDynamicBindings(buildLogger(logLevel, logPretty).child({}), () => ({
...collectMainInfo({ isBrowser: false }),
@@ -121,18 +103,10 @@ export default defineNuxtPlugin(async (nuxtApp) => {
// Send to consola for SSR log streaming in dev mode
if (import.meta.dev) {
const { consola } = await import('consola')
const ssrDevLogs = await initSsrDevLogs({ logLevel })
const consola = ssrDevLogs.consola
// (consola exports are sometimes being stripped from build for some reason, hence the extra checks)
if (consola) {
// remove print to stdout, pino already handles all that
consola.setReporters(
consola.options.reporters.filter(
(r) => get(r, 'constructor.name') !== 'FancyReporter'
)
)
consola.level = consolaLogLevels[logLevel] || 0
const unhandledHandler: AbstractUnhandledErrorHandler = ({
error,
message,
+2 -2
View File
@@ -119,7 +119,7 @@ const getOrInitInternalCache = async (params: {
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const val = vals[i]
if (!val) continue
if (!val || !key) continue
keyVals[key] = JSON.parse(val)
}
@@ -151,7 +151,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
nuxtApp.ssrContext!.payload.appCache = cacheToSend
})
} else if (import.meta.client) {
const restorable = window.__NUXT__?.appCache as Optional<Record<string, unknown>>
const restorable = nuxtApp.payload?.appCache
if (restorable) {
await internalCache.setMultiple(restorable)
}
@@ -3,7 +3,8 @@ import type { IncomingMessage } from 'node:http'
import { get } from 'lodash-es'
import type { Logger } from 'pino'
import type express from 'express'
import { prettifiedLoggerFactory } from '~/lib/core/helpers/observability'
import { prettifiedLoggerFactory, prettify } from '~/lib/core/helpers/observability'
import type { ConsolaInstance, LogType } from 'consola'
const redactedReqHeaders = ['authorization', 'cookie']
@@ -67,4 +68,53 @@ export const getRequestPath = (req: IncomingMessage | express.Request) => {
return path?.length ? path : null
}
export { prettifiedLoggerFactory }
// i dunno why but importing this returns undefined in server build, it makes no sense to me why it would be stripped out
// but the solution is to duplicate this here
const consolaLogLevels = {
silent: Number.NEGATIVE_INFINITY,
fatal: 0,
error: 0,
warn: 1,
log: 2,
info: 3,
success: 3,
fail: 3,
ready: 3,
start: 3,
box: 3,
debug: 4,
trace: 5,
verbose: Number.POSITIVE_INFINITY
}
interface DevLogsServerContext {
consola?: ConsolaInstance
}
export const initSsrDevLogs = async (params: { logLevel: LogType }) => {
const { getContext } = await import('unctx')
const { AsyncLocalStorage } = await import('node:async_hooks')
const asyncContext = getContext<DevLogsServerContext>('nuxt-dev-logs', {
asyncContext: true,
AsyncLocalStorage
})
const ctx = asyncContext.tryUse()
if (ctx?.consola) {
// Fix up
// remove print to stdout, pino already handles all that
ctx.consola.setReporters(
ctx.consola.options.reporters.filter(
(r) => get(r, 'constructor.name') !== 'FancyReporter'
)
)
ctx.consola.level = consolaLogLevels[params.logLevel] || 0
}
return {
consola: ctx?.consola
}
}
export { prettifiedLoggerFactory, prettify }
@@ -1,3 +1,4 @@
/// <reference types="../../type-augmentations/server.d.ts" />
import { defineEventHandler, fromNodeMiddleware } from 'h3'
import type { IncomingMessage, ServerResponse, IncomingHttpHeaders } from 'http'
import pino from 'pino'
@@ -103,7 +104,7 @@ export const LoggingMiddleware = pinoHttp({
headers: Record<string, string>
}
}
const realRaw = get(res, 'raw.raw') as typeof res.raw
const realRaw = get(res, 'raw.raw') as unknown as typeof res.raw
const isRequestCompleted = !!realRaw.writableEnded
const isRequestAborted = !isRequestCompleted
const statusCode = res.statusCode || res.raw.statusCode || realRaw.statusCode
@@ -0,0 +1,25 @@
import { getContext } from 'unctx'
import { consola, type ConsolaInstance } from 'consola'
import { AsyncLocalStorage } from 'node:async_hooks'
interface DevLogsServerContext {
consola: ConsolaInstance
}
const asyncContext = getContext<DevLogsServerContext>('nuxt-dev-logs', {
asyncContext: true,
AsyncLocalStorage
})
/**
* Importing `consola` from a nuxt plugin scope will give us a different instance. We have to pass through the nitro version
* through an async context.
*/
export default defineNitroPlugin((nitroApp) => {
if (!import.meta.dev) return
const handler = nitroApp.h3App.handler
nitroApp.h3App.handler = (event) => {
return asyncContext.callAsync({ consola }, () => handler(event))
}
})
+4
View File
@@ -15,6 +15,10 @@ declare module '#app' {
interface NuxtPayload {
serverFatalError?: import('~~/lib/core/helpers/observability').AbstractLoggerHandlerParams
apollo?: {
[clientKey: string]: Record<string, unknown>
}
appCache?: Record<string, unknown> | undefined
}
}
+2
View File
@@ -1,5 +1,7 @@
declare global {
interface Window {
__APOLLO_CLIENT__?: import('@apollo/client/core').ApolloClient<unknown>
DD_RUM?:
| Pick<import('@datadog/browser-rum').RumGlobal, 'onReady'>
| import('@datadog/browser-rum').RumGlobal
@@ -55,14 +55,12 @@ export const loggingExpressMiddleware = pinoHttp({
})
const getRequestPath = (req: IncomingMessage | Request) => {
const path = ((get(req, 'originalUrl') || get(req, 'url') || '') as string).split(
'?'
)[0]
const path = (get(req, 'originalUrl') || get(req, 'url') || '').split('?')[0]
return path?.length ? path : null
}
const getRequestParameters = (req: IncomingMessage | Request) => {
const maybeUrl = (get(req, 'originalUrl') as string) || get(req, 'url') || ''
const maybeUrl = get(req, 'originalUrl') || get(req, 'url') || ''
const url = parse(maybeUrl, true)
return url.query || {}
}
@@ -32,7 +32,9 @@ const isPayload = (payload: unknown): payload is AuthCodePayload =>
has(payload, 'code') &&
has(payload, 'userId') &&
has(payload, 'action') &&
Object.values(AuthCodePayloadAction).includes(get(payload, 'action'))
Object.values(AuthCodePayloadAction).includes(
get(payload, 'action') as unknown as AuthCodePayloadAction
)
)
export const createStoredAuthCodeFactory =
@@ -113,7 +113,12 @@ export const processNewFileStreamFactory = (): NewFileStreamProcessor => {
file.on('error', (err: unknown) => {
registerUploadResult(
markUploadError(deleteObject, streamId, blobId, get(err, 'message'))
markUploadError(
deleteObject,
streamId,
blobId,
get(err, 'message') as unknown as string
)
)
})
}
@@ -1655,7 +1655,13 @@ describe('Comments @comments', () => {
expect(data?.comments?.items?.length || 0).to.eq(1)
expect(errors?.length || 0).to.eq(0)
const textNode = get(data, 'comments.items[0].text.doc.content[0].content[0]')
const textNode = get(
data,
'comments.items[0].text.doc.content[0].content[0]'
) as unknown as {
text: string
marks: Array<{ type: string; attrs: Record<string, unknown> }>
}
expect(textNode.text).to.eq(item.text)
expect(textNode.marks).to.deep.equalInAnyOrder([
{
@@ -1716,7 +1722,13 @@ describe('Comments @comments', () => {
expect(data?.comments?.items?.length || 0).to.eq(1)
expect(errors?.length || 0).to.eq(0)
const textNodes = get(data, 'comments.items[0].text.doc.content[0].content')
const textNodes = get(
data,
'comments.items[0].text.doc.content[0].content'
) as unknown as Array<{
text: string
marks: Array<{ type: string; attrs: Record<string, unknown> }>
}>
expect(textNodes.length).to.eq(textParts.length)
range(textParts.length).forEach((i) => {
@@ -9,7 +9,7 @@ import type {
import { ModelNotFoundError } from '@/modules/core/errors/model'
import { ensureError } from '@speckle/shared'
import { FileImportJobNotFoundError } from '@/modules/fileuploads/helpers/errors'
import { get } from 'lodash-es'
import { get, isString } from 'lodash-es'
export const registerUploadCompleteAndStartFileImportFactory = (deps: {
registerCompletedUpload: RegisterCompletedUpload
@@ -55,10 +55,10 @@ export const registerUploadCompleteAndStartFileImportFactory = (deps: {
projectId: storedBlob.streamId //backwards compatibility
}
} catch (error) {
const message = get(error, 'message')
const message = get(error, 'message') as unknown as string | undefined
if (
message &&
typeof message === 'string' &&
isString(message) &&
message.includes(
'duplicate key value violates unique constraint "file_uploads_pkey"'
)
@@ -407,6 +407,6 @@ const setUpProjectReplication = async ({
const sanitizeError = (err: unknown): unknown => {
if (!err) return err
if (get(err, 'where').includes('password='))
if ((get(err, 'where') as unknown as string).includes('password='))
return { ...err, where: '[REDACTED AS IT CONTAINS CONNECTION STRING]' }
}
@@ -25,7 +25,7 @@ export const mockStoreHelpers = (store: IMockStore) => {
* for the existence of a field in the mock store.
*/
const hasField = (type: string, key: string, field: string) => {
const internalStore = get(store, 'store') as {
const internalStore = get(store, 'store') as unknown as {
[type: string]: {
[key: string]: {
[field: string]: unknown
@@ -145,7 +145,7 @@ export const LoggingExpressMiddleware = HttpLogger({
headers: Record<string, string>
}
}
const serverRes = get(res, 'raw.raw') as ServerResponse
const serverRes = get(res, 'raw.raw') as unknown as ServerResponse
const auth = serverRes.req.context
const statusCode = res.statusCode || res.raw.statusCode || serverRes.statusCode
@@ -1,7 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { trim, isNumber } from '#lodash'
import type { JSONContent } from '@tiptap/core'
import type { Optional } from '../../core/helpers/utilityTypes.js'
// TODO: had to copy out of tiptap/core, because of a build issue w/ a type-only import from CJS
type JSONContent = {
[key: string]: any
type?: string | undefined
attrs?: Record<string, any> | undefined
content?: JSONContent[]
marks?: {
type: string
attrs?: Record<string, any>
[key: string]: any
}[]
text?: string
}
/**
* Used to match URLs that can appear anywhere in a string, not perfect, but crafting a perfect
* URL regex is quite complex and we only need this for legacy comments
+4060 -13693
View File
File diff suppressed because it is too large Load Diff