feat(fileimport-service): add next gen file importer (#4697)

* feat(fileimport-service): add next gen file importer

* feat(fileimports): integrate server and fileimporter

* chore(dui3): remove leftover artifacts

* fix(server): test typing fixes

* fix(fileimports): test and pr comment fixes

* feat(fileimports: moare test fixes

* fix(fileimports): tests and yarn dedupe
This commit is contained in:
Gergő Jedlicska
2025-05-23 10:27:00 +02:00
committed by GitHub
parent 34cb632011
commit 2e86a723c6
29 changed files with 889 additions and 229 deletions
+3 -1
View File
@@ -28,7 +28,8 @@
"lint:eslint": "eslint .",
"start": "node --loader=./dist/src/aliasLoader.js ./bin/www.js",
"test": "NODE_ENV=test LOG_LEVEL=silent LOG_PRETTY=true vitest run --sequence.shuffle",
"downloadBlob": "node scripts/downloadBlob.js"
"downloadBlob": "node scripts/downloadBlob.js",
"publishTask": "tsx --env-file=.env scripts/publishTask.ts"
},
"dependencies": {
"@speckle/shared": "workspace:^",
@@ -63,6 +64,7 @@
"nodemon": "^2.0.20",
"prettier": "^2.5.1",
"rimraf": "^5.0.7",
"tsx": "^4.19.4",
"typescript": "^4.6.4",
"typescript-eslint": "^7.12.0",
"vitest": "^1.6.0"
@@ -0,0 +1,24 @@
import { REDIS_URL } from '../src/nextGen/config.js'
import { initializeQueue } from '@speckle/shared/queue'
import { JobPayload } from '@speckle/shared/workers/fileimport'
const jobQueue = await initializeQueue<JobPayload>({
queueName: 'fileimport-service-jobs',
redisUrl: REDIS_URL
})
await jobQueue.add({
serverUrl: '',
token: '',
jobId: '1',
projectId: '',
modelId: '',
blobId: '',
fileName: 'railing.ifc',
fileType: 'ifc',
timeOutSeconds: 100000000000
})
console.log('published')
process.exit()
+2 -1
View File
@@ -3,10 +3,11 @@ import * as Environment from '@speckle/shared/environment'
const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = Environment.getFeatureFlags()
import { main as oldMain } from '@/controller/daemon.js'
import { main } from '@/nextGen/main.js'
const start = () => {
if (FF_NEXT_GEN_FILE_IMPORTER_ENABLED) {
throw new Error('Not yet implemented')
void main()
} else {
void oldMain()
}
@@ -0,0 +1,25 @@
export function isSuccessOutput(
maybeSuccessOutput: unknown
): maybeSuccessOutput is { success: true; commitId: string } {
return (
!!maybeSuccessOutput &&
typeof maybeSuccessOutput === 'object' &&
'success' in maybeSuccessOutput &&
typeof maybeSuccessOutput.success === 'boolean' &&
maybeSuccessOutput.success &&
'commitId' in maybeSuccessOutput &&
typeof maybeSuccessOutput.commitId === 'string'
)
}
export function isErrorOutput(
maybeErrorOutput: unknown
): maybeErrorOutput is { success: false; error: string } {
return (
!!maybeErrorOutput &&
typeof maybeErrorOutput === 'object' &&
'error' in maybeErrorOutput &&
typeof maybeErrorOutput.error === 'string' &&
!!maybeErrorOutput.error
)
}
@@ -0,0 +1,85 @@
import { Logger } from 'pino'
import { spawn } from 'child_process'
import fs from 'fs'
export function runProcessWithTimeout(
processLogger: Logger,
cmd: string,
cmdArgs: string[],
extraEnv: Record<string, string>,
timeoutMs: number,
resultsPath: string
): Promise<void> {
return new Promise((resolve, reject) => {
let boundLogger = processLogger.child({ cmd, args: cmdArgs })
boundLogger.info('Starting process.')
const childProc = spawn(cmd, cmdArgs, { env: { ...process.env, ...extraEnv } })
boundLogger = boundLogger.child({ pid: childProc.pid })
childProc.stdout.on('data', (data) => {
handleData(data, false, boundLogger)
})
childProc.stderr.on('data', (data) => {
handleData(data, true, boundLogger)
})
let timedOut = false
const timeout = setTimeout(() => {
boundLogger.warn('Process timed out. Killing process...')
timedOut = true
childProc.kill(9)
const rejectionReason = `Timeout: Process took longer than ${timeoutMs} milliseconds to execute.`
const output = {
success: false,
error: rejectionReason
}
fs.writeFileSync(resultsPath, JSON.stringify(output))
reject(new Error(rejectionReason))
}, timeoutMs)
childProc.on('close', (code) => {
boundLogger.info({ exitCode: code }, "Process exited with code '{exitCode}'")
if (timedOut) {
return // ignore `close` calls after killing (the promise was already rejected)
}
clearTimeout(timeout)
if (code === 0) {
resolve()
} else {
reject(new Error(`Parser exited with code ${code}`))
}
})
})
}
export function handleData(data: unknown, isErr: boolean, logger: Logger) {
try {
if (!Buffer.isBuffer(data)) return
const dataAsString = data.toString()
dataAsString.split('\n').forEach((line) => {
if (!line) return
try {
JSON.parse(line) // verify if the data is already in JSON format
process.stdout.write('\n')
} catch {
wrapLogLine(line, isErr, logger)
}
})
} catch {
wrapLogLine(JSON.stringify(data), isErr, logger)
}
}
function wrapLogLine(line: string, isErr: boolean, logger: Logger) {
if (isErr) {
logger.error({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
return
}
logger.info({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
}
@@ -313,7 +313,7 @@ export class ServerAPI {
.where({ id: tokenId.slice(0, 10) })
.del()
if (delCount === 0) throw new Error('Token revokation failed')
if (delCount === 0) throw new Error('Token revocation failed')
return true
}
}
@@ -8,15 +8,15 @@ import { getDbClients } from '@/clients/knex.js'
import { downloadFile } from '@/controller/filesApi.js'
import fs from 'fs'
import { spawn } from 'child_process'
import { ServerAPI } from '@/controller/api.js'
import { downloadDependencies } from '@/controller/objDependencies.js'
import { logger } from '@/observability/logging.js'
import { Nullable, Scopes, wait, TIME_MS } from '@speckle/shared'
import { Knex } from 'knex'
import { Logger } from 'pino'
import { getIfcDllPath, useLegacyIfcImporter } from '@/controller/helpers/env.js'
import { isErrorOutput, isSuccessOutput } from '@/common/output.js'
import { runProcessWithTimeout } from '@/common/processHandling.js'
const HEALTHCHECK_FILE_PATH = '/tmp/last_successful_query'
@@ -178,7 +178,8 @@ async function doTask(
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
TIME_LIMIT,
TMP_RESULTS_PATH
)
} else {
await runProcessWithTimeout(
@@ -197,7 +198,8 @@ async function doTask(
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
TIME_LIMIT,
TMP_RESULTS_PATH
)
}
} else if (info.fileType.toLowerCase() === 'stl') {
@@ -219,7 +221,8 @@ async function doTask(
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
TIME_LIMIT,
TMP_RESULTS_PATH
)
} else if (info.fileType.toLowerCase() === 'obj') {
await downloadDependencies({
@@ -249,7 +252,8 @@ async function doTask(
{
USER_TOKEN: tempUserToken
},
TIME_LIMIT
TIME_LIMIT,
TMP_RESULTS_PATH
)
} else {
throw new Error(`File type ${info.fileType} is not supported`)
@@ -322,113 +326,6 @@ function maybeErrorToString(error: unknown): string {
}
}
function isSuccessOutput(
maybeSuccessOutput: unknown
): maybeSuccessOutput is { success: true; commitId: string } {
return (
!!maybeSuccessOutput &&
typeof maybeSuccessOutput === 'object' &&
'success' in maybeSuccessOutput &&
typeof maybeSuccessOutput.success === 'boolean' &&
maybeSuccessOutput.success &&
'commitId' in maybeSuccessOutput &&
typeof maybeSuccessOutput.commitId === 'string'
)
}
function isErrorOutput(
maybeErrorOutput: unknown
): maybeErrorOutput is { success: false; error: string } {
return (
!!maybeErrorOutput &&
typeof maybeErrorOutput === 'object' &&
'error' in maybeErrorOutput &&
typeof maybeErrorOutput.error === 'string' &&
!!maybeErrorOutput.error
)
}
function runProcessWithTimeout(
processLogger: Logger,
cmd: string,
cmdArgs: string[],
extraEnv: Record<string, string>,
timeoutMs: number
): Promise<void> {
return new Promise((resolve, reject) => {
let boundLogger = processLogger.child({ cmd, args: cmdArgs })
boundLogger.info('Starting process.')
const childProc = spawn(cmd, cmdArgs, { env: { ...process.env, ...extraEnv } })
boundLogger = boundLogger.child({ pid: childProc.pid })
childProc.stdout.on('data', (data) => {
handleData(data, false, boundLogger)
})
childProc.stderr.on('data', (data) => {
handleData(data, true, boundLogger)
})
let timedOut = false
const timeout = setTimeout(() => {
boundLogger.warn('Process timed out. Killing process...')
timedOut = true
childProc.kill(9)
const rejectionReason = `Timeout: Process took longer than ${timeoutMs} milliseconds to execute.`
const output = {
success: false,
error: rejectionReason
}
fs.writeFileSync(TMP_RESULTS_PATH, JSON.stringify(output))
reject(new Error(rejectionReason))
}, timeoutMs)
childProc.on('close', (code) => {
boundLogger.info({ exitCode: code }, "Process exited with code '{exitCode}'")
if (timedOut) {
return // ignore `close` calls after killing (the promise was already rejected)
}
clearTimeout(timeout)
if (code === 0) {
resolve()
} else {
reject(new Error(`Parser exited with code ${code}`))
}
})
})
}
function handleData(data: unknown, isErr: boolean, logger: Logger) {
try {
if (!Buffer.isBuffer(data)) return
const dataAsString = data.toString()
dataAsString.split('\n').forEach((line) => {
if (!line) return
try {
JSON.parse(line) // verify if the data is already in JSON format
process.stdout.write('\n')
} catch {
wrapLogLine(line, isErr, logger)
}
})
} catch {
wrapLogLine(JSON.stringify(data), isErr, logger)
}
}
function wrapLogLine(line: string, isErr: boolean, logger: Logger) {
if (isErr) {
logger.error({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
return
}
logger.info({ parserLogLine: line }, 'ParserLog: {parserLogLine}')
}
const doStuff = async () => {
const dbClients = await getDbClients()
const mainDb = dbClients.main.public
@@ -42,7 +42,7 @@ export const getIfcDllPath = () => {
if (isDevOrTestEnv()) {
const possiblePath = path.resolve(
getPackageRootDirPath(),
'./src/ifc-dotnet/output/ifc-converter.dll'
'./src/ifc-dotnet/bin/Release/net8.0/ifc-converter.dll'
)
if (file.existsSync(possiblePath)) {
cachedIfcDllPath = absolutePath
@@ -0,0 +1,20 @@
import { z } from 'zod'
import { parseEnv } from 'znv'
export const {
REDIS_URL,
FILEIMPORT_TIMEOUT,
LOG_LEVEL,
LOG_PRETTY,
DOTNET_BINARY_PATH,
PYTHON_BINARY_PATH,
RHINO_IMPORTER_PATH
} = parseEnv(process.env, {
REDIS_URL: z.string().url(),
FILEIMPORT_TIMEOUT: z.number().default(3600000),
LOG_LEVEL: z.string().default('info'),
LOG_PRETTY: z.boolean().default(false),
DOTNET_BINARY_PATH: z.string().default('dotnet'),
PYTHON_BINARY_PATH: z.string().default('python3'),
RHINO_IMPORTER_PATH: z.string().default('rhino-importer.exe')
})
@@ -0,0 +1,144 @@
import { downloadFile } from '@/controller/filesApi.js'
import { AppState } from '@speckle/shared/workers'
import { JobPayload, FileImportResultPayload } from '@speckle/shared/workers/fileimport'
import { Logger } from 'pino'
import { tmpdir } from 'node:os'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import fs from 'fs'
import { runProcessWithTimeout } from '@/common/processHandling.js'
import { DOTNET_BINARY_PATH, RHINO_IMPORTER_PATH } from './config.js'
import { getIfcDllPath } from '@/controller/helpers/env.js'
import { z } from 'zod'
const jobSuccess = z.object({
success: z.literal(true),
commitId: z.string()
})
const jobError = z.object({
success: z.literal(false),
error: z.string()
})
const jobResult = z.discriminatedUnion('success', [jobSuccess, jobError])
type JobArgs = {
job: JobPayload
timeout: number
logger: Logger
getAppState: () => AppState
getElapsed: () => number
}
export const jobProcessor = async ({
job,
timeout,
logger,
getAppState,
getElapsed
}: JobArgs): Promise<FileImportResultPayload> => {
const taskLogger = logger
const jobMessage =
'Processed job {jobId} with result {status}. It took {elapsed} seconds.'
const tmp = tmpdir()
const jobDir = path.join(tmp, job.jobId)
fs.rmSync(jobDir, { force: true, recursive: true })
fs.mkdirSync(jobDir)
try {
const fileType = job.fileType.toLowerCase()
const sourceFilePath = path.join(jobDir, job.fileName)
const resultsPath = path.join(jobDir, 'import_results.json')
await downloadFile({
speckleServerUrl: job.serverUrl,
fileId: job.blobId,
streamId: job.projectId,
token: job.token,
destination: sourceFilePath,
logger
})
switch (fileType) {
case 'ifc':
await runProcessWithTimeout(
taskLogger,
DOTNET_BINARY_PATH,
[
getIfcDllPath(),
sourceFilePath,
resultsPath,
job.projectId,
`File upload: ${job.fileName}`,
job.modelId,
'bogus',
'regionName'
],
{
USER_TOKEN: job.token
},
timeout,
resultsPath
)
break
case 'stl':
case 'obj':
await runProcessWithTimeout(
taskLogger,
RHINO_IMPORTER_PATH,
[
sourceFilePath,
resultsPath,
job.projectId,
job.modelId,
job.serverUrl,
job.token
],
{
USER_TOKEN: job.token
},
timeout,
resultsPath
)
break
default:
throw new Error(`File type ${fileType} is not supported`)
}
const output = jobResult.safeParse(JSON.parse(readFileSync(resultsPath, 'utf8')))
if (!output.success) {
throw new Error('could not parse the result file')
}
if (!output.data.success) {
throw new Error(output.data.error)
}
const versionId = output.data.commitId
return {
status: 'success',
result: { versionId, durationSeconds: getElapsed() },
warnings: []
}
} catch (err) {
if (getAppState() === AppState.SHUTTINGDOWN) {
// likely that the job was cancelled due to the service shutting down
logger.warn({ err, elapsed: getElapsed(), status: 'error' }, jobMessage)
} else {
logger.error({ err, elapsed: getElapsed(), status: 'error' }, jobMessage)
}
const reason = err instanceof Error ? err.stack ?? err.toString() : 'unknown error'
return {
status: 'error',
result: {
durationSeconds: getElapsed()
},
reason
}
} finally {
fs.rmdirSync(jobDir, { recursive: true })
}
}
@@ -0,0 +1,150 @@
import { AppState } from '@speckle/shared/workers'
import { initializeQueue } from '@speckle/shared/queue'
import { FILEIMPORT_TIMEOUT, REDIS_URL } from '@/nextGen/config.js'
import type {
JobPayload,
FileImportResultPayload
} from '@speckle/shared/workers/fileimport'
import type Bull from 'bull'
import { logger } from '@/observability/logging.js'
import { Logger } from 'pino'
import { ensureError, TIME_MS } from '@speckle/shared'
import { jobProcessor } from './jobProcessor.js'
const JobQueueName = 'fileimport-service-jobs'
let jobQueue: Bull.Queue<JobPayload> | undefined = undefined
let appState: AppState = AppState.STARTING
let currentJob: { logger: Logger; done: Bull.DoneCallback } | undefined = undefined
export const main = async () => {
// we discussed doing push based metrics from here
// await initMetrics({ app, registry: initPrometheusRegistry() })
// store this callback, so on shutdown we can error the job
try {
jobQueue = await initializeQueue<JobPayload>({
queueName: JobQueueName,
redisUrl: REDIS_URL
})
} catch (e) {
const err = ensureError(e, 'Unknown error creating job queue')
logger.error({ err }, 'Error creating job queue')
// the callback to server.listen has failed, so we need to exit the process and not just return
await beforeShutdown() // handle the shutdown gracefully
onShutdown()
process.exit(1)
}
appState = AppState.RUNNING
logger.debug(`Starting processing of "${JobQueueName}" message queue`)
await jobQueue.process(async (payload, done) => {
const elapsed = (() => {
const start = new Date().getTime()
return () => (new Date().getTime() - start) / TIME_MS.second
})()
let encounteredError = false
let jobLogger = logger.child({
payloadId: payload.id,
jobPriorAttemptsMade: payload.attemptsMade
})
const job = payload.data
try {
currentJob = { done, logger: jobLogger }
jobLogger = jobLogger.child({
jobId: job.jobId,
serverUrl: job.serverUrl
})
const result = await jobProcessor({
job,
logger,
timeout: FILEIMPORT_TIMEOUT,
getAppState: () => appState,
getElapsed: elapsed
})
await sendResult({
...job,
result
})
} catch (err) {
if (appState === AppState.SHUTTINGDOWN) {
// likely that the job was cancelled due to the service shutting down
jobLogger.warn({ err }, 'Processing {jobId} failed')
} else {
jobLogger.error({ err }, 'Processing {jobId} failed')
}
if (err instanceof Error) {
encounteredError = true
done(err)
await sendResult({
...job,
result: {
status: 'error',
reason: err.message,
result: {
durationSeconds: 0
}
}
})
} else {
throw err
}
} finally {
if (!encounteredError) done()
currentJob = undefined
}
})
}
const sendResult = async ({
serverUrl,
projectId,
jobId,
token,
result
}: {
serverUrl: string
projectId: string
jobId: string
token: string
result: FileImportResultPayload
}) => {
const response = await fetch(
`${serverUrl}/api/projects/${projectId}/fileimporter/jobs/${jobId}/results`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(result)
}
)
if (!response.ok) {
const text = await response.text()
currentJob?.logger.error({ cause: text }, 'Failed to report job result')
throw new Error(`Failed to report job result: ${text}`)
}
}
const beforeShutdown = async () => {
logger.info('🛑 Beginning shut down, pausing all jobs')
appState = AppState.SHUTTINGDOWN
// stop accepting new jobs and kill any running jobs
if (jobQueue) {
await jobQueue.pause(
true, // just pausing this local worker of the queue
true // do not wait for active jobs to finish
)
}
if (currentJob) {
currentJob.logger.warn('Cancelling job due to fileimport-service shutdown')
currentJob.done(new Error('Job cancelled due to fileimport-service shutdown'))
}
// no need to close the job queue and redis client, when the process exits they will be closed automatically
}
const onShutdown = () => {
logger.info('👋 Completed shut down, now exiting')
}
+1 -1
View File
@@ -103,6 +103,6 @@
"ts-node": {
"swc": true
},
"include": ["src/**/*", "vitest.config.ts"],
"include": ["src/**/*", "scripts/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "coverage", "reports"]
}
@@ -6,6 +6,9 @@ import tseslint from 'typescript-eslint'
*/
const configs = [
...baseConfigs,
{
ignores: ['dist', 'public', 'docs']
},
{
files: ['**/*.js'],
languageOptions: {
@@ -0,0 +1,7 @@
import { BaseError } from '@/modules/shared/errors'
export class ModelNotFoundError extends BaseError {
static defaultMessage = 'Attempting to work with a non-existant model'
static code = 'MODEL_NOT_FOUND'
static statusCode = 404
}
@@ -4,9 +4,8 @@ import {
FileUploadRecordV2
} from '@/modules/fileuploads/helpers/types'
import { Optional } from '@speckle/shared'
import { FileImportResultPayload } from '@speckle/shared/dist/commonjs/workers/fileimport/job.js'
import { JobFileImportPayload } from '@/modules/fileuploads/queues/fileimports'
import { UploadResult } from '@/modules/blobstorage/domain/types'
import { FileImportResultPayload, JobPayload } from '@speckle/shared/workers/fileimport'
export type GetFileInfo = (args: {
fileId: string
@@ -49,13 +48,16 @@ export type UpdateFileStatus = (params: {
fileId: string
status: FileUploadConvertedStatus
convertedMessage: string
convertedCommitId: string | null
}) => Promise<FileUploadRecord>
export type UploadedFile = UploadResult & { userId: string }
export type FileImportMessage = Pick<
JobFileImportPayload,
'modelId' | 'projectId' | 'fileType' | 'blobId'
JobPayload,
'modelId' | 'projectId' | 'fileType' | 'fileName' | 'blobId'
> & { jobId: string; userId: string }
export type ScheduleFileimportJob = (args: JobPayload) => Promise<void>
export type PushJobToFileImporter = (args: FileImportMessage) => Promise<void>
@@ -22,7 +22,7 @@ export const jobResultToConvertedMessage = (jobResult: FileImportResultPayload)
case 'success':
return jobResult.warnings.join('; ')
case 'error':
return jobResult.reasons.join('; ')
return jobResult.reason
default:
throw new FileImportInvalidJobResultPayload('Unknown job result status')
}
@@ -0,0 +1,16 @@
import { Knex } from 'knex'
const FILEUPLOADS_TABLE = 'file_uploads'
const MESSAGE_FIELD = 'convertedMessage'
export async function up(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TABLE ${FILEUPLOADS_TABLE} ALTER COLUMN "${MESSAGE_FIELD}" TYPE text;`
)
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(
`ALTER TABLE ${FILEUPLOADS_TABLE} ALTER COLUMN "${MESSAGE_FIELD}" TYPE varchar(255);`
)
}
@@ -10,27 +10,14 @@ import { Optional, TIME_MS } from '@speckle/shared'
import Bull from 'bull'
import cryptoRandomString from 'crypto-random-string'
import { initializeQueue as setupQueue } from '@speckle/shared/dist/commonjs/queue/index.js'
import { JobPayload } from '@speckle/shared/workers/fileimport'
import { ScheduleFileimportJob } from '@/modules/fileuploads/domain/operations'
const FILE_IMPORT_SERVICE_QUEUE_NAME = isTestEnv()
? `test:fileimport-service-jobs:${cryptoRandomString({ length: 5 })}`
: 'fileimport-service-jobs'
export type JobFileImportPayload = {
blobId: string
modelId: string
projectId: string
url: string
token: string
fileType: string
timeOutSeconds: number
}
export type FileImportJob = {
type: 'file-import'
payload: JobFileImportPayload
}
let queue: Optional<Bull.Queue<FileImportJob>>
let queue: Optional<Bull.Queue<JobPayload>>
if (isTestEnv()) {
logger.info(`Fileimport service test queue ID: ${FILE_IMPORT_SERVICE_QUEUE_NAME}`)
@@ -71,13 +58,12 @@ export const shutdownQueue = async () => {
await queue.close()
}
export const scheduleJob = async (jobData: FileImportJob): Promise<string> => {
export const scheduleJob: ScheduleFileimportJob = async (jobData) => {
if (!queue) {
throw new UninitializedResourceAccessError(
'Attempting to use uninitialized Bull queue'
)
}
const job = await queue.add(jobData, { removeOnComplete: true, attempts: 3 })
return job.id.toString()
await queue.add(jobData, { removeOnComplete: true, attempts: 3 })
}
@@ -218,13 +218,14 @@ export const getBranchPendingVersionsFactory =
export const updateFileStatusFactory =
(deps: { db: Knex }): UpdateFileStatus =>
async (params) => {
const { fileId, status, convertedMessage } = params
const { fileId, status, convertedMessage, convertedCommitId } = params
const fileInfos = await tables
.fileUploads(deps.db)
.update<FileUploadRecord[]>({
[FileUploads.withoutTablePrefix.col.convertedStatus]: status,
[FileUploads.withoutTablePrefix.col.convertedLastUpdate]: knex.fn.now(),
[FileUploads.withoutTablePrefix.col.convertedMessage]: convertedMessage
[FileUploads.withoutTablePrefix.col.convertedMessage]: convertedMessage,
[FileUploads.withoutTablePrefix.col.convertedCommitId]: convertedCommitId
})
.where({ [FileUploads.withoutTablePrefix.col.id]: fileId })
.returning<FileUploadRecord[]>('*')
@@ -11,7 +11,6 @@ import {
saveUploadFileFactoryV2,
updateFileStatusFactory
} from '@/modules/fileuploads/repositories/fileUploads'
import { FileImportInvalidJobResultPayload } from '@/modules/fileuploads/helpers/errors'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
import { processNewFileStreamFactory } from '@/modules/blobstorage/services/streams'
@@ -32,26 +31,29 @@ import {
import { pushJobToFileImporterFactory } from '@/modules/fileuploads/services/createFileImport'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import { scheduleJob } from '@/modules/fileuploads/queues/fileimports'
import { ModelNotFoundError } from '@/modules/core/errors/model'
export const nextGenFileImporterRouterFactory = (): Router => {
const processNewFileStream = processNewFileStreamFactory()
const app = Router()
app.post(
'/api/projects/:streamId/fileimporter/jobs',
'/api/projects/:streamId/models/:modelId/fileimporter/jobs',
authMiddlewareCreator(
streamWritePermissionsPipelineFactory({
getStream: getStreamFactory({ db })
})
),
validateRequest({
query: z.object({
params: z.object({
// needs to be streamId, due to the auth context building
streamId: z.string(),
modelId: z.string()
})
}),
async (req, res) => {
const projectId = req.params.streamId
const modelId = req.query.modelId
const modelId = req.params.modelId
const userId = req.context.userId
if (!userId) throw new UnauthorizedError('User not authorized')
@@ -65,7 +67,7 @@ export const nextGenFileImporterRouterFactory = (): Router => {
const projectDb = await getProjectDbClient({ projectId })
const getModelsByIds = getBranchesByIdsFactory({ db: projectDb })
const [model] = await getModelsByIds([modelId], { streamId: projectId })
if (!model) throw new UnauthorizedError()
if (!model) throw new ModelNotFoundError(undefined, { statusCode: 401 })
const pushJobToFileImporter = pushJobToFileImporterFactory({
getServerOrigin,
@@ -142,6 +144,13 @@ export const nextGenFileImporterRouterFactory = (): Router => {
getStream: getStreamFactory({ db })
})
),
validateRequest({
params: z.object({
streamId: z.string(),
jobId: z.string()
}),
body: fileImportResultPayload
}),
async (req, res) => {
const userId = req.context.userId
const projectId = req.params.streamId
@@ -153,15 +162,7 @@ export const nextGenFileImporterRouterFactory = (): Router => {
jobId
})
const parseJobOutput = fileImportResultPayload.safeParse(req.body)
if (!parseJobOutput.success) {
logger.error(
{ err: parseJobOutput.error.format() },
'Error parsing file import job result'
)
throw new FileImportInvalidJobResultPayload(parseJobOutput.error.message)
}
const jobResult = parseJobOutput.data
const jobResult = req.body
const projectDb = await getProjectDbClient({ projectId })
@@ -2,24 +2,33 @@ import { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operations'
import { DefaultAppIds } from '@/modules/auth/defaultApps'
import { Scopes, TIME, TIME_MS } from '@speckle/shared'
import { TokenResourceIdentifierType } from '@/test/graphql/generated/graphql'
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
import { scheduleJob } from '@/modules/fileuploads/queues/fileimports'
import { PushJobToFileImporter } from '@/modules/fileuploads/domain/operations'
import {
PushJobToFileImporter,
ScheduleFileimportJob
} from '@/modules/fileuploads/domain/operations'
const twentyMinutes = 20 * TIME.minute
export const pushJobToFileImporterFactory =
(deps: {
createAppToken: CreateAndStoreAppToken
getServerOrigin: typeof getServerOrigin
scheduleJob: typeof scheduleJob
getServerOrigin: () => string
scheduleJob: ScheduleFileimportJob
}): PushJobToFileImporter =>
async ({ modelId, projectId, userId, fileType, blobId, jobId }): Promise<void> => {
async ({
modelId,
projectId,
userId,
fileName,
fileType,
blobId,
jobId
}): Promise<void> => {
const token = await deps.createAppToken({
appId: DefaultAppIds.Web,
name: `fileimport-${projectId}@${modelId}`,
userId,
scopes: [Scopes.Streams.Write],
scopes: [Scopes.Streams.Write, Scopes.Streams.Read],
lifespan: 2 * TIME_MS.hour,
limitResources: [
{
@@ -29,21 +38,15 @@ export const pushJobToFileImporterFactory =
]
})
const url = new URL(
`/projects/${projectId}/fileimporter/jobs/${jobId}/results`,
deps.getServerOrigin()
).toString()
await deps.scheduleJob({
type: 'file-import',
payload: {
token,
url,
modelId,
fileType,
projectId,
timeOutSeconds: twentyMinutes,
blobId
}
jobId,
fileName,
token,
serverUrl: deps.getServerOrigin(),
modelId,
fileType,
projectId,
timeOutSeconds: twentyMinutes,
blobId
})
}
@@ -81,6 +81,7 @@ export const insertNewUploadAndNotifyFactoryV2 =
})
await deps.pushJobToFileImporter({
fileName: file.fileName,
fileType: file.fileType,
projectId: file.projectId,
modelId: upload.modelId,
@@ -34,12 +34,21 @@ export const onFileImportResultFactory =
const status = jobResultStatusToFileUploadStatus(jobResult.status)
const convertedMessage = jobResultToConvertedMessage(jobResult)
let convertedCommitId = null
switch (jobResult.status) {
case 'error':
break
case 'success':
convertedCommitId = jobResult.result.versionId
}
let updatedFile
try {
updatedFile = await deps.updateFileStatus({
fileId: jobId,
status,
convertedMessage
convertedMessage,
convertedCommitId
})
} catch (e) {
const err = ensureError(e)
@@ -29,6 +29,7 @@ export const buildFileUploadMessage = (
modelId: cryptoRandomString({ length: 10 }),
projectId: cryptoRandomString({ length: 10 }),
fileType: cryptoRandomString({ length: 10 }),
fileName: cryptoRandomString({ length: 10 }),
blobId: cryptoRandomString({ length: 10 }),
userId: cryptoRandomString({ length: 10 }),
jobId: cryptoRandomString({ length: 10 })
@@ -20,9 +20,8 @@ import { BranchRecord } from '@/modules/core/helpers/types'
const { createUser, createStream, createToken, createBranch } =
initUploadTestEnvironment()
const fileImporterUrl = (projectOneId: string, modelId?: string) =>
`/api/projects/${projectOneId}/fileimporter/jobs` +
(modelId ? `?modelId=${modelId}` : ``)
const fileImporterUrl = (projectOneId: string, modelId: string) =>
`/api/projects/${projectOneId}/models/${modelId}/fileimporter/jobs`
const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
@@ -17,6 +17,10 @@ import type { Server } from 'http'
import request from 'supertest'
import { initUploadTestEnvironment } from '@/modules/fileuploads/tests/helpers/init'
import { createFileUploadJob } from '@/modules/fileuploads/tests/helpers/creation'
import {
FileImportErrorPayload,
FileImportSuccessPayload
} from '@speckle/shared/workers/fileimport'
const { createUser, createStream, createToken } = initUploadTestEnvironment()
@@ -159,6 +163,14 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
expect(response.status).to.equal(400)
})
it('should 400 if the job id cannot be found', async () => {
const payload: FileImportSuccessPayload = {
status: 'success',
warnings: [],
result: {
versionId: cryptoRandomString({ length: 10 }),
durationSeconds: randomInt(1, 3600)
}
}
const response = await request(app)
.post(
`/api/projects/${projectOneId}/fileimporter/jobs/${cryptoRandomString({
@@ -167,34 +179,24 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
)
.set('Content-Type', 'application/json')
.set('Authorization', `Bearer ${userOneToken}`)
.send(
JSON.stringify({
status: 'success',
warnings: [],
result: {
versionId: cryptoRandomString({ length: 10 }),
durationSeconds: randomInt(1, 3600)
}
})
)
.send(JSON.stringify(payload))
expect(response.status).to.equal(404)
})
it('should 200 if the payload reports a success result', async () => {
const payload: FileImportSuccessPayload = {
status: 'success',
warnings: [],
result: {
versionId: cryptoRandomString({ length: 10 }),
durationSeconds: randomInt(1, 3600)
}
}
const response = await request(app)
.post(`/api/projects/${projectOneId}/fileimporter/jobs/${jobOneId}/results`)
.set('Authorization', `Bearer ${userOneToken}`)
.set('Content-Type', 'application/json')
.send(
JSON.stringify({
status: 'success',
warnings: [],
result: {
versionId: cryptoRandomString({ length: 10 }),
durationSeconds: randomInt(1, 3600)
}
})
)
.send(JSON.stringify(payload))
expect(response.status).to.equal(200)
const gqlResponse = await getFileUploads(projectOneId, userOneToken)
@@ -205,19 +207,18 @@ const { FF_NEXT_GEN_FILE_IMPORTER_ENABLED } = getFeatureFlags()
)
})
it('should 200 if the payload reports an error result', async () => {
const payload: FileImportErrorPayload = {
status: 'error',
reason: cryptoRandomString({ length: 10 }),
result: {
durationSeconds: randomInt(1, 3600)
}
}
const response = await request(app)
.post(`/api/projects/${projectOneId}/fileimporter/jobs/${jobOneId}/results`)
.set('Authorization', `Bearer ${userOneToken}`)
.set('Content-Type', 'application/json')
.send(
JSON.stringify({
status: 'error',
reasons: [cryptoRandomString({ length: 10 })],
result: {
durationSeconds: randomInt(1, 3600)
}
})
)
.send(JSON.stringify(payload))
expect(response.status).to.equal(200)
const gqlResponse = await getFileUploads(projectOneId, userOneToken)
@@ -17,6 +17,7 @@ import { pushJobToFileImporterFactory } from '@/modules/fileuploads/services/cre
import { assign } from 'lodash'
import { buildFileUploadMessage } from '@/modules/fileuploads/tests/helpers/creation'
import { getFeatureFlags } from '@speckle/shared/environment'
import { JobPayload } from '@speckle/shared/workers/fileimport'
const { createStream, createUser, garbageCollector } = initUploadTestEnvironment()
@@ -110,7 +111,6 @@ describe('FileUploads @fileuploads', () => {
getServerOrigin: () => serverOrigin,
scheduleJob: async (jobData) => {
assign(result, jobData)
return Promise.resolve(cryptoRandomString({ length: 10 }))
},
createAppToken: (args) => {
usedUserId = args.userId
@@ -121,18 +121,18 @@ describe('FileUploads @fileuploads', () => {
await pushJobToFileImporter(upload)
expect(usedUserId).to.equal(upload.userId)
expect(result).to.deep.equal({
type: 'file-import',
payload: {
token,
url: `${serverOrigin}/projects/${upload.projectId}/fileimporter/jobs/${upload.jobId}/results`,
modelId: upload.modelId,
fileType: upload.fileType,
projectId: upload.projectId,
timeOutSeconds: 1200,
blobId: upload.blobId
}
})
const expected: JobPayload = {
jobId: upload.jobId,
fileName: upload.fileName,
token,
serverUrl: serverOrigin,
modelId: upload.modelId,
fileType: upload.fileType,
projectId: upload.projectId,
timeOutSeconds: 1200,
blobId: upload.blobId
}
expect(result).to.deep.equal(expected)
})
}
)
@@ -7,11 +7,13 @@ const job = z.object({
export const jobPayload = job.merge(
z.object({
url: z.string(),
serverUrl: z.string().url().describe('The url of the server'),
projectId: z.string(),
modelId: z.string(),
token: z.string(),
responseUrl: z.string().url(),
blobId: z.string(),
fileType: z.string(),
fileName: z.string(),
timeOutSeconds: z
.number()
.int()
@@ -36,10 +38,12 @@ export type FileImportSuccessPayload = z.infer<typeof fileImportSuccessPayload>
const fileImportErrorPayload = z.object({
status: z.literal('error'),
reasons: z.array(z.string()).min(1),
reason: z.string(),
result: baseFileImportResult
})
export type FileImportErrorPayload = z.infer<typeof fileImportErrorPayload>
export const fileImportResultPayload = z.discriminatedUnion('status', [
fileImportSuccessPayload,
fileImportErrorPayload
+278
View File
@@ -8879,6 +8879,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/aix-ppc64@npm:0.25.4"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/android-arm64@npm:0.17.19"
@@ -8935,6 +8942,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm64@npm:0.25.4"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/android-arm@npm:0.17.19"
@@ -8991,6 +9005,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm@npm:0.25.4"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/android-x64@npm:0.17.19"
@@ -9047,6 +9068,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-x64@npm:0.25.4"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/darwin-arm64@npm:0.17.19"
@@ -9103,6 +9131,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-arm64@npm:0.25.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/darwin-x64@npm:0.17.19"
@@ -9159,6 +9194,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-x64@npm:0.25.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/freebsd-arm64@npm:0.17.19"
@@ -9215,6 +9257,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-arm64@npm:0.25.4"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/freebsd-x64@npm:0.17.19"
@@ -9271,6 +9320,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-x64@npm:0.25.4"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-arm64@npm:0.17.19"
@@ -9327,6 +9383,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm64@npm:0.25.4"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-arm@npm:0.17.19"
@@ -9383,6 +9446,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm@npm:0.25.4"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-ia32@npm:0.17.19"
@@ -9439,6 +9509,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ia32@npm:0.25.4"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-loong64@npm:0.17.19"
@@ -9495,6 +9572,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-loong64@npm:0.25.4"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-mips64el@npm:0.17.19"
@@ -9551,6 +9635,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-mips64el@npm:0.25.4"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-ppc64@npm:0.17.19"
@@ -9607,6 +9698,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ppc64@npm:0.25.4"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-riscv64@npm:0.17.19"
@@ -9663,6 +9761,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-riscv64@npm:0.25.4"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-s390x@npm:0.17.19"
@@ -9719,6 +9824,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-s390x@npm:0.25.4"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/linux-x64@npm:0.17.19"
@@ -9775,6 +9887,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-x64@npm:0.25.4"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.24.2":
version: 0.24.2
resolution: "@esbuild/netbsd-arm64@npm:0.24.2"
@@ -9789,6 +9908,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-arm64@npm:0.25.4"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/netbsd-x64@npm:0.17.19"
@@ -9845,6 +9971,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-x64@npm:0.25.4"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.23.1":
version: 0.23.1
resolution: "@esbuild/openbsd-arm64@npm:0.23.1"
@@ -9866,6 +9999,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-arm64@npm:0.25.4"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/openbsd-x64@npm:0.17.19"
@@ -9922,6 +10062,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-x64@npm:0.25.4"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/sunos-x64@npm:0.17.19"
@@ -9978,6 +10125,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/sunos-x64@npm:0.25.4"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/win32-arm64@npm:0.17.19"
@@ -10034,6 +10188,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-arm64@npm:0.25.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/win32-ia32@npm:0.17.19"
@@ -10090,6 +10251,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-ia32@npm:0.25.4"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.17.19":
version: 0.17.19
resolution: "@esbuild/win32-x64@npm:0.17.19"
@@ -10146,6 +10314,13 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-x64@npm:0.25.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0":
version: 4.4.0
resolution: "@eslint-community/eslint-utils@npm:4.4.0"
@@ -15650,6 +15825,7 @@ __metadata:
prom-client: "npm:^14.0.1"
rimraf: "npm:^5.0.7"
tarn: "npm:^3.0.2"
tsx: "npm:^4.19.4"
typescript: "npm:^4.6.4"
typescript-eslint: "npm:^7.12.0"
undici: "npm:^5.28.4"
@@ -28181,6 +28357,92 @@ __metadata:
languageName: node
linkType: hard
"esbuild@npm:~0.25.0":
version: 0.25.4
resolution: "esbuild@npm:0.25.4"
dependencies:
"@esbuild/aix-ppc64": "npm:0.25.4"
"@esbuild/android-arm": "npm:0.25.4"
"@esbuild/android-arm64": "npm:0.25.4"
"@esbuild/android-x64": "npm:0.25.4"
"@esbuild/darwin-arm64": "npm:0.25.4"
"@esbuild/darwin-x64": "npm:0.25.4"
"@esbuild/freebsd-arm64": "npm:0.25.4"
"@esbuild/freebsd-x64": "npm:0.25.4"
"@esbuild/linux-arm": "npm:0.25.4"
"@esbuild/linux-arm64": "npm:0.25.4"
"@esbuild/linux-ia32": "npm:0.25.4"
"@esbuild/linux-loong64": "npm:0.25.4"
"@esbuild/linux-mips64el": "npm:0.25.4"
"@esbuild/linux-ppc64": "npm:0.25.4"
"@esbuild/linux-riscv64": "npm:0.25.4"
"@esbuild/linux-s390x": "npm:0.25.4"
"@esbuild/linux-x64": "npm:0.25.4"
"@esbuild/netbsd-arm64": "npm:0.25.4"
"@esbuild/netbsd-x64": "npm:0.25.4"
"@esbuild/openbsd-arm64": "npm:0.25.4"
"@esbuild/openbsd-x64": "npm:0.25.4"
"@esbuild/sunos-x64": "npm:0.25.4"
"@esbuild/win32-arm64": "npm:0.25.4"
"@esbuild/win32-ia32": "npm:0.25.4"
"@esbuild/win32-x64": "npm:0.25.4"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
"@esbuild/android-arm":
optional: true
"@esbuild/android-arm64":
optional: true
"@esbuild/android-x64":
optional: true
"@esbuild/darwin-arm64":
optional: true
"@esbuild/darwin-x64":
optional: true
"@esbuild/freebsd-arm64":
optional: true
"@esbuild/freebsd-x64":
optional: true
"@esbuild/linux-arm":
optional: true
"@esbuild/linux-arm64":
optional: true
"@esbuild/linux-ia32":
optional: true
"@esbuild/linux-loong64":
optional: true
"@esbuild/linux-mips64el":
optional: true
"@esbuild/linux-ppc64":
optional: true
"@esbuild/linux-riscv64":
optional: true
"@esbuild/linux-s390x":
optional: true
"@esbuild/linux-x64":
optional: true
"@esbuild/netbsd-arm64":
optional: true
"@esbuild/netbsd-x64":
optional: true
"@esbuild/openbsd-arm64":
optional: true
"@esbuild/openbsd-x64":
optional: true
"@esbuild/sunos-x64":
optional: true
"@esbuild/win32-arm64":
optional: true
"@esbuild/win32-ia32":
optional: true
"@esbuild/win32-x64":
optional: true
bin:
esbuild: bin/esbuild
checksum: 10/227ffe9b31f0b184a0b0a0210bb9d32b2b115b8c5c9b09f08db2c3928cb470fc55a22dbba3c2894365d3abcc62c2089b85638be96a20691d1234d31990ea01b2
languageName: node
linkType: hard
"escalade@npm:^3.1.1":
version: 3.1.1
resolution: "escalade@npm:3.1.1"
@@ -47416,6 +47678,22 @@ __metadata:
languageName: node
linkType: hard
"tsx@npm:^4.19.4":
version: 4.19.4
resolution: "tsx@npm:4.19.4"
dependencies:
esbuild: "npm:~0.25.0"
fsevents: "npm:~2.3.3"
get-tsconfig: "npm:^4.7.5"
dependenciesMeta:
fsevents:
optional: true
bin:
tsx: dist/cli.mjs
checksum: 10/4dde315aeda70b9cadfecbc8d05b1625f5831018b9cb2db25cbbd03c5f5ee9c59cdc6652a0fd8492176b50944a5af1d5af352b944d024f4a719f58d6f2ac3a7f
languageName: node
linkType: hard
"tunnel-agent@npm:^0.6.0":
version: 0.6.0
resolution: "tunnel-agent@npm:0.6.0"