Files
speckle-connectors-dui/lib/bridge/server.ts
T
2026-02-03 10:06:46 +02:00

544 lines
16 KiB
TypeScript

import type { ConversionResult } from '~/lib/conversions/conversionResult'
import type { ProgressStage } from '@speckle/objectloader'
import ObjectLoader from '@speckle/objectloader'
import { send, type Base } from '@speckle/objectsender'
import { provideApolloClient, useMutation } from '@vue/apollo-composable'
import {
versionDetailsQuery,
markReceivedVersionMutation,
createVersionMutation
} from '~/lib/graphql/mutationsAndQueries'
import { storeToRefs } from 'pinia'
import type { DUIAccount } from '~/store/accounts'
import { useAccountStore } from '~/store/accounts'
import { useHostAppStore } from '~/store/hostApp'
import type { Emitter } from 'nanoevents'
import { useDesktopService } from '~/lib/core/composables/desktopService'
import type { ToastNotification } from '@speckle/ui-components'
import { ToastNotificationType } from '@speckle/ui-components'
import { useModelIngestion } from '../ingestion/composables/useModelIngestion'
import type { ISenderModelCard } from '../models/card/send'
import { useCheckGraphql } from '~/lib/core/composables/useCheckGraphql'
export type SendBatchViaBrowserArgs = {
modelCardId: string
projectId: string
token: string
serverUrl: string
batch: string
currentBatch: number
totalBatch: number
referencedObjectId: string
}
export type CreateVersionViaBrowserArgs = {
modelCardId: string
projectId: string
modelId: string
token: string
serverUrl: string
accountId: string
message: string
referencedObjectId: string
sourceApplication: string
sendConversionResults: ConversionResult[]
}
export type SendViaBrowserArgs = {
modelCardId: string
projectId: string
modelId: string
token: string
serverUrl: string
accountId: string
message: string
sendConversionResults: ConversionResult[]
sendObject: {
id: string // the root object id which should be used for creating the version
rootObject: object // NOTE to dim
}
}
export type ReceiveViaBrowserArgs = {
modelCardId: string
projectId: string
modelId: string
objectId: string
accountId: string
selectedVersionId: string
}
export type ReceiveViaDesktopServiceArgs = {
modelCardId: string
projectId: string
modelId: string
objectId: string
accountId: string
selectedVersionId: string
xmlConverterPath: string
endpointVersion: string // i.e. v1, v2...
}
export type CreateVersionArgs = {
modelCardId: string
projectId: string
modelId: string
accountId: string
referencedObjectId: string
message?: string
sourceApplication?: string
}
export type ArchicadReceiveRequest = {
accountId: string
projectId: string
referencedObject: string
xmlConverterPath: string
}
// TODO: Once ruby codebase aligned with it, sketchup will consume this bridge too!
export class ArchicadBridge {
public emitter: Emitter
constructor(emitter: Emitter) {
this.emitter = emitter
}
// NOTE: Overriden emit as we do not need to parse the data back - the Server bridge already parses it for us.
emit(
eventName: string,
payload: Record<string, unknown>,
runMethod: (
methodName: string,
args: unknown[],
shouldTimeout: boolean
) => Promise<unknown>
): void {
const eventPayload = payload as unknown as Record<string, unknown>
if (eventName === 'sendByBrowser')
this.sendByBrowser(eventPayload as SendViaBrowserArgs)
// we will switch to https://www.npmjs.com/package/@speckle/objectsender
else if (eventName === 'sendBatchViaBrowser')
this.sendBatchViaBrowser(eventPayload as SendBatchViaBrowserArgs, runMethod)
else if (eventName === 'createVersionViaBrowser')
this.createVersionViaBrowser(eventPayload as CreateVersionViaBrowserArgs)
else if (eventName === 'receiveByBrowser')
this.receiveByBrowser(eventPayload as ReceiveViaBrowserArgs, runMethod)
else if (eventName === 'receiveByDesktopService')
this.receiveByDesktopService(
eventPayload as ReceiveViaDesktopServiceArgs,
runMethod
)
// Archicad is not likely to hit here yet!
else return this.emitter.emit(eventName, eventPayload)
}
private async receiveByDesktopService(
eventPayload: ReceiveViaDesktopServiceArgs,
runMethod: (
methodName: string,
args: unknown[],
shouldTimeout: boolean
) => Promise<unknown>
) {
const { pingDesktopService } = useDesktopService()
// 1 - Ping the desktop service to understand it is running
const isDesktopServiceAvailable = await pingDesktopService()
const hostAppStore = useHostAppStore()
if (!isDesktopServiceAvailable) {
const notification: ToastNotification = {
title: 'Desktop service unavailable',
description:
'Falling back to a slower load process because the desktop service is not running.',
type: ToastNotificationType.Info
}
hostAppStore.setNotification(notification)
// 1.1 - No - fallback to receiveByBrowser
return this.receiveByBrowser(
{
modelCardId: eventPayload.modelCardId,
accountId: eventPayload.accountId,
projectId: eventPayload.projectId,
modelId: eventPayload.modelId,
objectId: eventPayload.objectId,
selectedVersionId: eventPayload.selectedVersionId
},
runMethod
)
}
const accountStore = useAccountStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find(
(acc) => acc.accountInfo.id === eventPayload.accountId
)
provideApolloClient((account as DUIAccount).client)
// useQuery cannot use in outside of VueComponent.
const result = await (account as DUIAccount).client.query({
query: versionDetailsQuery,
variables: {
projectId: eventPayload.projectId,
versionId: eventPayload.selectedVersionId,
modelId: eventPayload.modelId
}
})
// 1.2 - Yes - continue
const body: ArchicadReceiveRequest = {
accountId: eventPayload.accountId,
projectId: eventPayload.projectId,
referencedObject: result.data.project.model.version.referencedObject as string,
xmlConverterPath: eventPayload.xmlConverterPath
}
// 2 - POST the desktop service with formatted endpoint
try {
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: {
status: 'Conversion has started, Archicad may be unresponsive for a while.'
}
})
const res = await fetch(
`http://localhost:29364/${eventPayload.endpointVersion}/archicad-receive`,
{
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
}
)
if (!res.ok) {
const errorText = await res.text() // it is weird tho we can use .json() when it is not ok, it just throws and as below is OK.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
throw new Error(`${JSON.parse(errorText).detail as string}`)
}
const path = (await res.json()) as unknown
await runMethod(
'afterGsmConverter',
[
eventPayload.modelCardId,
result.data.project.model.version.sourceApplication,
path
] as unknown as unknown[],
false
)
} catch (error) {
const notification: ToastNotification = {
title: 'Load failed',
description: error as string,
type: ToastNotificationType.Danger
}
hostAppStore.setNotification(notification)
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: undefined
})
}
}
private async receiveByBrowser(
eventPayload: ReceiveViaBrowserArgs,
runMethod: (
methodName: string,
args: unknown[],
shouldTimeout: boolean
) => Promise<unknown>
) {
const accountStore = useAccountStore()
const hostAppStore = useHostAppStore()
const { accounts } = storeToRefs(accountStore)
const account = accounts.value.find(
(acc) => acc.accountInfo.id === eventPayload.accountId
)
provideApolloClient((account as DUIAccount).client)
// useQuery cannot use in outside of VueComponent.
const result = await (account as DUIAccount).client.query({
query: versionDetailsQuery,
variables: {
projectId: eventPayload.projectId,
versionId: eventPayload.selectedVersionId,
modelId: eventPayload.modelId
}
})
const loader = new ObjectLoader({
serverUrl: account?.accountInfo.serverInfo.url as string,
token: account?.accountInfo.token as string,
streamId: eventPayload.projectId,
objectId: result.data.project.model.version.referencedObject as string
})
const updateProgress = (e: {
stage: ProgressStage
current: number
total: number
}) => {
const progress = e.current / e.total
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: { status: 'Downloading', progress }
})
}
// eslint-disable-next-line @typescript-eslint/await-thenable
const rootObj = await loader.getAndConstructObject(updateProgress)
const args = [
eventPayload.modelCardId,
result.data.project.model.version.sourceApplication,
rootObj
]
const markReceived = provideApolloClient((account as DUIAccount).client)(() =>
useMutation(markReceivedVersionMutation)
)
await markReceived.mutate({
input: {
versionId: eventPayload.selectedVersionId,
projectId: eventPayload.projectId,
sourceApplication: hostAppStore.hostAppName as string
}
})
hostAppStore.handleModelProgressEvents({
modelCardId: eventPayload.modelCardId,
progress: { status: 'Converting' }
})
// CONVERSION WILL START AFTER THAT
await runMethod('afterGetObjects', args as unknown as unknown[], false)
}
private queuedPromises = {} as Record<string, Promise<Response>[]>
/**
* Internal server method for sending batch data via REST api.
* Whenever batches are completed it triggers to host application to notify it is done.
* To be able to use this function properly, expected objects in batch must have hashed (speckle ids generated, detached, chucked bla bla) on connector.
* @param eventPayload
*/
private async sendBatchViaBrowser(
eventPayload: SendBatchViaBrowserArgs,
runMethod: (
methodName: string,
args: unknown[],
shouldTimeout: boolean
) => Promise<unknown>
) {
const {
serverUrl,
token,
projectId,
modelCardId,
batch,
totalBatch,
currentBatch,
referencedObjectId
} = eventPayload
if (!this.queuedPromises[modelCardId]) {
this.queuedPromises[modelCardId] = []
}
const formData = new FormData()
formData.append(`batch-1`, new Blob([batch], { type: 'application/json' }))
this.queuedPromises[modelCardId].push(
fetch(`${serverUrl}/objects/${projectId}`, {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: formData
})
)
// 🚀 ready to send!!!!
if (currentBatch === totalBatch) {
const start = performance.now()
for (let i = 0; i < this.queuedPromises[modelCardId].length; i++) {
const isLast = i === this.queuedPromises[modelCardId].length - 1
// Emit progress update for each resolved promise
this.emitter.emit('setModelProgress', {
modelCardId,
progress: {
status: 'Uploading',
progress: isLast ? 0 : (i + 1) / this.queuedPromises[modelCardId].length
}
} as unknown as string)
await this.queuedPromises[modelCardId][i] // Wait for the current promise to resolve
}
this.queuedPromises[modelCardId] = []
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
const args = [eventPayload.modelCardId, referencedObjectId]
await runMethod('afterSendObjects', args as unknown as unknown[], false)
}
}
/**
* Whenever we make sure we sent every object to the server, we can safely call this function from connector to trigger version create and populate conversion reports.
* @param eventPayload
*/
private async createVersionViaBrowser(eventPayload: CreateVersionViaBrowserArgs) {
const {
projectId,
accountId,
modelId,
modelCardId,
referencedObjectId,
message,
sourceApplication,
sendConversionResults
} = eventPayload
const versionId = await this.createVersion({
modelCardId,
projectId,
modelId,
accountId,
referencedObjectId,
sourceApplication,
message
})
const hostAppStore = useHostAppStore()
hostAppStore.setModelSendResult({
modelCardId,
versionId: versionId as string,
sendConversionResults
})
}
/**
* Internal server bridge method for sending data via object sender.
* @param eventPayload
*/
private async sendByBrowser(eventPayload: SendViaBrowserArgs) {
const {
serverUrl,
token,
projectId,
accountId,
modelId,
modelCardId,
sendObject,
sendConversionResults,
message
} = eventPayload
this.emitter.emit('setModelProgress', {
modelCardId,
progress: {
status: 'Uploading',
progress: 0
}
} as unknown as string)
const { hash: rootCommitObjectId } = await send(
sendObject.rootObject as unknown as Base,
{
serverUrl,
projectId,
token
}
)
const hostAppStore = useHostAppStore()
const hostAppName = `Archicad ${hostAppStore.hostAppVersion}`
const args: CreateVersionArgs = {
modelCardId,
projectId,
modelId,
accountId,
referencedObjectId: rootCommitObjectId,
sourceApplication: hostAppName,
message: message || `send from ${hostAppStore.hostAppName?.toLowerCase()}`
}
const versionId = await this.createVersion(args)
// TODO: Alignment needed
hostAppStore.setModelSendResult({
modelCardId: args.modelCardId,
versionId: versionId as string,
sendConversionResults
})
}
private async createVersion(args: CreateVersionArgs) {
const hostAppStore = useHostAppStore()
const { completeIngestionWithVersion } = useModelIngestion()
const { canCreateModelIngestion } = useCheckGraphql()
const modelCard = hostAppStore.models.find(
(model) => model.modelCardId === args.modelCardId
) as ISenderModelCard
const canCreateIngestion = await canCreateModelIngestion(
modelCard.projectId,
modelCard.modelId,
modelCard.accountId
)
if (canCreateIngestion.queryAvailable) {
const ingestionId = hostAppStore.activeIngestions[args.modelCardId]
if (!ingestionId) {
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: 'Ingestion ID not found to create version.'
})
throw new Error(`Ingestion failed: Ingestion ID not found to create version.`)
}
const res = await completeIngestionWithVersion(
modelCard,
ingestionId,
args.referencedObjectId
)
if (res?.statusData.__typename === 'ModelIngestionSuccessStatus') {
return res?.statusData.versionId
}
if (res?.statusData.__typename === 'ModelIngestionFailedStatus') {
const errorReason = res?.statusData.errorReason || 'Unknown error'
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Failed',
description: errorReason
})
throw new Error(`Ingestion failed: ${errorReason}.`)
}
hostAppStore.setNotification({
type: ToastNotificationType.Danger,
title: 'Ingestion Error',
description: 'Ingestion status does not match expected types.'
})
throw new Error(
`Ingestion status does not match with the expected types as success or failure.`
)
} else {
const accountStore = useAccountStore()
const account = accountStore.getAccountClient(args.accountId)
const { mutate } = provideApolloClient(account)(() =>
useMutation(createVersionMutation)
)
const result = await mutate({
input: {
modelId: args.modelId,
objectId: args.referencedObjectId,
sourceApplication: args.sourceApplication || 'Archicad',
projectId: args.projectId
}
})
return result?.data?.versionMutations?.create?.id
}
}
}