diff --git a/.gitignore b/.gitignore index 40773d23f..af0e06cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ .DS_Store node_modules .pnpm-store -build +/apps/ui/build +/build .svelte-kit package .env @@ -13,7 +14,7 @@ apps/api/db/migration.db-journal apps/api/core* apps/backup/backups/* !apps/backup/backups/.gitkeep -logs +/logs others/certificates backups/* !backups/.gitkeep diff --git a/apps/client/package.json b/apps/client/package.json index 220c9b248..4704b174a 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -42,6 +42,7 @@ "@trpc/server": "10.1.0", "cuid": "2.1.8", "daisyui": "2.41.0", + "dayjs": "1.11.6", "flowbite-svelte": "0.28.0", "js-cookie": "3.0.1", "js-yaml": "4.1.0", diff --git a/apps/client/src/lib/common.ts b/apps/client/src/lib/common.ts index 168c36ed1..8c2361a0c 100644 --- a/apps/client/src/lib/common.ts +++ b/apps/client/src/lib/common.ts @@ -183,3 +183,19 @@ export function put( ): Promise> { return send({ method: 'PUT', path, data, headers }); } +export function changeQueryParams(buildId: string) { + const queryParams = new URLSearchParams(window.location.search); + queryParams.set('buildId', buildId); + // @ts-ignore + return history.pushState(null, null, '?' + queryParams.toString()); +} + +export const dateOptions: any = { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false +}; \ No newline at end of file diff --git a/apps/client/src/lib/dayjs.ts b/apps/client/src/lib/dayjs.ts new file mode 100644 index 000000000..9ff5b0a1a --- /dev/null +++ b/apps/client/src/lib/dayjs.ts @@ -0,0 +1,7 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +dayjs.extend(utc); +dayjs.extend(relativeTime); + +export { dayjs as day }; diff --git a/apps/client/src/lib/store.ts b/apps/client/src/lib/store.ts index 77510ba95..87afa0034 100644 --- a/apps/client/src/lib/store.ts +++ b/apps/client/src/lib/store.ts @@ -170,3 +170,4 @@ export const setLocation = (resource: any, settings?: any) => { disabledButton.set(false); } }; +export const selectedBuildId: any = writable(null) diff --git a/apps/client/src/routes/+page.svelte b/apps/client/src/routes/+page.svelte index b26c9c4f1..bee821f5b 100644 --- a/apps/client/src/routes/+page.svelte +++ b/apps/client/src/routes/+page.svelte @@ -11,7 +11,7 @@ import DatabaseIcons from '$lib/components/icons/databases/DatabaseIcons.svelte'; import ServiceIcons from '$lib/components/icons/services/ServiceIcons.svelte'; import * as Icons from '$lib/components/icons'; - import NewResource from './_components/NewResource.svelte'; + import NewResource from './components/NewResource.svelte'; const { applications, diff --git a/apps/client/src/routes/applications/[id]/+layout.svelte b/apps/client/src/routes/applications/[id]/+layout.svelte index 89e214266..8edaae1b8 100644 --- a/apps/client/src/routes/applications/[id]/+layout.svelte +++ b/apps/client/src/routes/applications/[id]/+layout.svelte @@ -3,10 +3,10 @@ import { status, trpc } from '$lib/store'; import { onDestroy, onMount } from 'svelte'; import type { LayoutData } from './$types'; - import * as Buttons from './_components/Buttons'; - import * as States from './_components/States'; + import * as Buttons from './components/Buttons'; + import * as States from './components/States'; - import Menu from './_components/Menu.svelte'; + import Menu from './components/Menu.svelte'; export let data: LayoutData; const id = $page.params.id; diff --git a/apps/client/src/routes/applications/[id]/builds/+page.svelte b/apps/client/src/routes/applications/[id]/builds/+page.svelte new file mode 100644 index 000000000..8df74e4ac --- /dev/null +++ b/apps/client/src/routes/applications/[id]/builds/+page.svelte @@ -0,0 +1,204 @@ + + +
+
+
+
Build Logs
+ +
+
+
+ +
+
+
+
+
+ {#if $selectedBuildId} + {#key $selectedBuildId} + + {/key} + {:else if buildCount === 0} + Not build logs found. + {:else} + Select a build to see the logs. + {/if} +
+
+
+
+ +
+ {#each builds as build, index (build.id)} + +
loadBuild(build.id)} + class:rounded-tr={index === 0} + class:rounded-br={index === builds.length - 1} + class="flex cursor-pointer items-center justify-center py-4 no-underline transition-all duration-150 hover:bg-coolgray-300 hover:shadow-xl" + class:bg-coolgray-200={$selectedBuildId === build.id} + > +
+
+ {build.branch || application.branch} +
+
+ {build.type} +
+
+ {build.status} +
+
+ +
+ {#if build.status === 'running'} +
+ {build.elapsed}s +
+ {:else if build.status !== 'queued'} +
{day(build.updatedAt).utc().fromNow()}
+
+ Finished in + {day(build.updatedAt).utc().diff(day(build.createdAt)) / 1000}s +
+ {/if} +
+
+ {new Intl.DateTimeFormat('default', dateOptions).format(new Date(build.createdAt)) + + `\n`} + {/each} +
+
+
diff --git a/apps/client/src/routes/applications/[id]/builds/+page.ts b/apps/client/src/routes/applications/[id]/builds/+page.ts new file mode 100644 index 000000000..f6d6570b8 --- /dev/null +++ b/apps/client/src/routes/applications/[id]/builds/+page.ts @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import { trpc } from '$lib/store'; +import type { PageLoad } from './$types'; +export const ssr = false; + +export const load: PageLoad = async ({ params }) => { + try { + const { id } = params; + const data = await trpc.applications.getBuilds.query({ id, skip: 0 }); + return data; + } catch (err) { + throw error(500, { + message: 'An unexpected error occurred, please try again later.' + }); + } +}; diff --git a/apps/client/src/routes/applications/[id]/builds/BuildLog.svelte b/apps/client/src/routes/applications/[id]/builds/BuildLog.svelte new file mode 100644 index 000000000..81e515e25 --- /dev/null +++ b/apps/client/src/routes/applications/[id]/builds/BuildLog.svelte @@ -0,0 +1,215 @@ + + +
+ + + + {#if currentStatus === 'running'} +
+{#if currentStatus === 'queued'} +
+ Queued and waiting for execution. +
+{:else if logs.length > 0} +
+ {#each logs as log} + {#if fromDb} + {log.line + '\n'} + {:else} + [{day.unix(log.time).format('HH:mm:ss.SSS')}] {log.line + '\n'} + {/if} + {/each} +
+{:else} +
+ {loading + ? 'Loading logs...' + : dev + ? 'In development, logs are shown in the console.' + : 'No logs found yet.'} +
+{/if} diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/Delete.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/Delete.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/Delete.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/Delete.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/Deploy.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/Deploy.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/Deploy.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/Deploy.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/ForceDeploy.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/ForceDeploy.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/ForceDeploy.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/ForceDeploy.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/Loading.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/Loading.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/Loading.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/Loading.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/Restart.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/Restart.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/Restart.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/Restart.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/Stop.svelte b/apps/client/src/routes/applications/[id]/components/Buttons/Stop.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/Stop.svelte rename to apps/client/src/routes/applications/[id]/components/Buttons/Stop.svelte diff --git a/apps/client/src/routes/applications/[id]/_components/Buttons/index.ts b/apps/client/src/routes/applications/[id]/components/Buttons/index.ts similarity index 100% rename from apps/client/src/routes/applications/[id]/_components/Buttons/index.ts rename to apps/client/src/routes/applications/[id]/components/Buttons/index.ts diff --git a/apps/client/src/routes/applications/[id]/_components/Menu.svelte b/apps/client/src/routes/applications/[id]/components/Menu.svelte similarity index 98% rename from apps/client/src/routes/applications/[id]/_components/Menu.svelte rename to apps/client/src/routes/applications/[id]/components/Menu.svelte index 4c7ddb76c..c8e9c9cf4 100644 --- a/apps/client/src/routes/applications/[id]/_components/Menu.svelte +++ b/apps/client/src/routes/applications/[id]/components/Menu.svelte @@ -149,9 +149,9 @@
  • - + import { page } from '$app/stores'; + import { errorNotification } from '$lib/common'; + import { trpc } from '$lib/store'; + import { onMount, onDestroy } from 'svelte'; + + let application: any = {}; + let logsLoading = false; + let loadLogsInterval: any = null; + let logs: any = []; + let lastLog: any = null; + let followingInterval: any; + let followingLogs: any; + let logsEl: any; + let position = 0; + let services: any = []; + let selectedService: any = null; + let noContainer = false; + + const { id } = $page.params; + onMount(async () => { + const { data } = await trpc.applications.getApplicationById.query({ id }); + application = data; + if (data.dockerComposeFile) { + services = normalizeDockerServices(JSON.parse(data.dockerComposeFile).services); + } else { + services = [ + { + name: '' + } + ]; + await selectService(''); + } + }); + onDestroy(() => { + clearInterval(loadLogsInterval); + clearInterval(followingInterval); + }); + function normalizeDockerServices(services: any[]) { + const tempdockerComposeServices = []; + for (const [name, data] of Object.entries(services)) { + tempdockerComposeServices.push({ + name, + data + }); + } + return tempdockerComposeServices; + } + async function loadLogs() { + if (logsLoading) return; + try { + const newLogs = await trpc.applications.loadLogs.query({ + id, + containerId: selectedService, + since: Number(lastLog?.split(' ')[0]) || 0 + }); + + if (newLogs.noContainer) { + noContainer = true; + } else { + noContainer = false; + } + if (newLogs?.logs && newLogs.logs[newLogs.logs.length - 1] !== logs[logs.length - 1]) { + logs = logs.concat(newLogs.logs); + lastLog = newLogs.logs[newLogs.logs.length - 1]; + } + } catch (error) { + return errorNotification(error); + } + } + function detect() { + if (position < logsEl.scrollTop) { + position = logsEl.scrollTop; + } else { + if (followingLogs) { + clearInterval(followingInterval); + followingLogs = false; + } + position = logsEl.scrollTop; + } + } + + function followBuild() { + followingLogs = !followingLogs; + if (followingLogs) { + followingInterval = setInterval(() => { + logsEl.scrollTop = logsEl.scrollHeight; + window.scrollTo(0, document.body.scrollHeight); + }, 1000); + } else { + clearInterval(followingInterval); + } + } + async function selectService(service: any, init: boolean = false) { + if (loadLogsInterval) clearInterval(loadLogsInterval); + if (followingInterval) clearInterval(followingInterval); + + logs = []; + lastLog = null; + followingLogs = false; + + selectedService = `${application.id}${service.name ? `-${service.name}` : ''}`; + loadLogs(); + loadLogsInterval = setInterval(() => { + loadLogs(); + }, 1000); + } + + +
    +
    +
    Application Logs
    +
    +
    +
    + {#each services as service} + + {/each} +
    + +{#if selectedService} +
    + {#if logs.length === 0} + {#if noContainer} +
    Container not found / exited.
    + {/if} + {:else} +
    +
    + + {#if loadLogsInterval} + + {/if} +
    +
    + {#each logs as log} +

    {log + '\n'}

    + {/each} +
    +
    + {/if} +
    +{/if} diff --git a/apps/client/src/routes/applications/[id]/secrets/+page.svelte b/apps/client/src/routes/applications/[id]/secrets/+page.svelte index a2bebc5dd..e0a4544a9 100644 --- a/apps/client/src/routes/applications/[id]/secrets/+page.svelte +++ b/apps/client/src/routes/applications/[id]/secrets/+page.svelte @@ -9,8 +9,8 @@ import pLimit from 'p-limit'; import { page } from '$app/stores'; import { addToast, trpc } from '$lib/store'; - import Secret from './_components/Secret.svelte'; - import PreviewSecret from './_components/PreviewSecret.svelte'; + import Secret from './components/Secret.svelte'; + import PreviewSecret from './components/PreviewSecret.svelte'; import { errorNotification } from '$lib/common'; import Explainer from '$lib/components/Explainer.svelte'; diff --git a/apps/client/src/routes/applications/[id]/secrets/_components/PreviewSecret.svelte b/apps/client/src/routes/applications/[id]/secrets/components/PreviewSecret.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/secrets/_components/PreviewSecret.svelte rename to apps/client/src/routes/applications/[id]/secrets/components/PreviewSecret.svelte diff --git a/apps/client/src/routes/applications/[id]/secrets/_components/Secret.svelte b/apps/client/src/routes/applications/[id]/secrets/components/Secret.svelte similarity index 100% rename from apps/client/src/routes/applications/[id]/secrets/_components/Secret.svelte rename to apps/client/src/routes/applications/[id]/secrets/components/Secret.svelte diff --git a/apps/client/src/routes/_components/NewResource.svelte b/apps/client/src/routes/components/NewResource.svelte similarity index 100% rename from apps/client/src/routes/_components/NewResource.svelte rename to apps/client/src/routes/components/NewResource.svelte diff --git a/apps/server/package.json b/apps/server/package.json index 77b310fef..b75ee8510 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,17 +16,23 @@ "db:migrate": "DATABASE_URL=file:../db/migration.db prisma migrate dev --skip-seed --name" }, "dependencies": { + "@breejs/ts-worker": "2.0.0", "@fastify/autoload": "5.6.0", "@fastify/cors": "8.2.0", "@fastify/env": "4.1.0", "@fastify/jwt": "6.5.0", "@fastify/static": "6.6.0", "@fastify/websocket": "7.1.1", + "@ladjs/graceful": "3.0.2", "@prisma/client": "4.6.1", "@trpc/client": "10.1.0", "@trpc/server": "10.1.0", "abort-controller": "3.0.0", + "axe": "11.0.0", "bcryptjs": "2.4.3", + "bree": "9.1.2", + "cabin": "11.0.1", + "csvtojson": "2.0.10", "cuid": "2.1.8", "dayjs": "1.11.6", "dotenv": "^16.0.3", @@ -39,9 +45,12 @@ "js-yaml": "4.1.0", "jsonwebtoken": "8.5.1", "node-fetch": "3.3.0", + "p-all": "4.0.0", + "p-throttle": "5.0.0", "prisma": "4.6.1", "shell-quote": "^1.7.4", "ssh-config": "4.1.6", + "strip-ansi": "7.0.1", "superjson": "1.11.0", "tslib": "2.4.1", "unique-names-generator": "4.7.1", diff --git a/apps/server/src/jobs/deployApplication.ts b/apps/server/src/jobs/deployApplication.ts new file mode 100644 index 000000000..6f79b94e8 --- /dev/null +++ b/apps/server/src/jobs/deployApplication.ts @@ -0,0 +1,807 @@ +import { parentPort } from 'node:worker_threads'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; + +import { + copyBaseConfigurationFiles, + makeLabelForSimpleDockerfile, + makeLabelForStandaloneApplication, + saveBuildLog, + saveDockerRegistryCredentials, + setDefaultConfiguration +} from '../lib/buildPacks/common'; +import { + createDirectories, + decrypt, + defaultComposeConfiguration, + getDomain, + prisma, + decryptApplication, + isDev, + pushToRegistry, + executeCommand, + generateSecrets +} from '../lib/common'; +import * as importers from '../lib/importers'; +import * as buildpacks from '../lib/buildPacks'; + +(async () => { + if (parentPort) { + parentPort.on('message', async (message) => { + if (message === 'error') throw new Error('oops'); + if (message === 'cancel') { + parentPort.postMessage('cancelled'); + await prisma.$disconnect(); + process.exit(0); + } + }); + const pThrottle = await import('p-throttle'); + const throttle = pThrottle.default({ + limit: 1, + interval: 2000 + }); + + const th = throttle(async () => { + try { + const queuedBuilds = await prisma.build.findMany({ + where: { status: { in: ['queued', 'running'] } }, + orderBy: { createdAt: 'asc' } + }); + const { concurrentBuilds } = await prisma.setting.findFirst({}); + if (queuedBuilds.length > 0) { + parentPort.postMessage({ deploying: true }); + const concurrency = concurrentBuilds; + const pAll = await import('p-all'); + const actions = []; + + for (const queueBuild of queuedBuilds) { + actions.push(async () => { + let application = await prisma.application.findUnique({ + where: { id: queueBuild.applicationId }, + include: { + dockerRegistry: true, + destinationDocker: true, + gitSource: { include: { githubApp: true, gitlabApp: true } }, + persistentStorage: true, + secrets: true, + settings: true, + teams: true + } + }); + + let { + id: buildId, + type, + gitSourceId, + sourceBranch = null, + pullmergeRequestId = null, + previewApplicationId = null, + forceRebuild, + sourceRepository = null + } = queueBuild; + application = decryptApplication(application); + + if (!gitSourceId && application.simpleDockerfile) { + const { + id: applicationId, + destinationDocker, + destinationDockerId, + secrets, + port, + persistentStorage, + exposePort, + simpleDockerfile, + dockerRegistry + } = application; + const { workdir } = await createDirectories({ repository: applicationId, buildId }); + try { + if (queueBuild.status === 'running') { + await saveBuildLog({ + line: 'Building halted, restarting...', + buildId, + applicationId: application.id + }); + } + const volumes = + persistentStorage?.map((storage) => { + if (storage.oldPath) { + return `${applicationId}${storage.path + .replace(/\//gi, '-') + .replace('-app', '')}:${storage.path}`; + } + return `${applicationId}${storage.path.replace(/\//gi, '-')}:${storage.path}`; + }) || []; + + if (destinationDockerId) { + await prisma.build.update({ + where: { id: buildId }, + data: { status: 'running' } + }); + try { + const { stdout: containers } = await executeCommand({ + dockerId: destinationDockerId, + command: `docker ps -a --filter 'label=com.docker.compose.service=${applicationId}' --format {{.ID}}` + }); + if (containers) { + const containerArray = containers.split('\n'); + if (containerArray.length > 0) { + for (const container of containerArray) { + await executeCommand({ + dockerId: destinationDockerId, + command: `docker stop -t 0 ${container}` + }); + await executeCommand({ + dockerId: destinationDockerId, + command: `docker rm --force ${container}` + }); + } + } + } + } catch (error) { + // + } + let envs = []; + if (secrets.length > 0) { + envs = [ + ...envs, + ...generateSecrets(secrets, pullmergeRequestId, false, port) + ]; + } + await fs.writeFile(`${workdir}/Dockerfile`, simpleDockerfile); + if (dockerRegistry) { + const { url, username, password } = dockerRegistry; + await saveDockerRegistryCredentials({ url, username, password, workdir }); + } + + const labels = makeLabelForSimpleDockerfile({ + applicationId, + type, + port: exposePort ? `${exposePort}:${port}` : port + }); + try { + const composeVolumes = volumes.map((volume) => { + return { + [`${volume.split(':')[0]}`]: { + name: volume.split(':')[0] + } + }; + }); + const composeFile = { + version: '3.8', + services: { + [applicationId]: { + build: { + context: workdir + }, + image: `${applicationId}:${buildId}`, + container_name: applicationId, + volumes, + labels, + environment: envs, + depends_on: [], + expose: [port], + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + ...defaultComposeConfiguration(destinationDocker.network) + } + }, + networks: { + [destinationDocker.network]: { + external: true + } + }, + volumes: Object.assign({}, ...composeVolumes) + }; + await fs.writeFile(`${workdir}/docker-compose.yml`, yaml.dump(composeFile)); + await executeCommand({ + debug: true, + dockerId: destinationDocker.id, + command: `docker compose --project-directory ${workdir} up -d` + }); + await saveBuildLog({ line: 'Deployed 🎉', buildId, applicationId }); + } catch (error) { + await saveBuildLog({ line: error, buildId, applicationId }); + const foundBuild = await prisma.build.findUnique({ where: { id: buildId } }); + if (foundBuild) { + await prisma.build.update({ + where: { id: buildId }, + data: { + status: 'failed' + } + }); + } + throw new Error(error); + } + } + } catch (error) { + const foundBuild = await prisma.build.findUnique({ where: { id: buildId } }); + if (foundBuild) { + await prisma.build.update({ + where: { id: buildId }, + data: { + status: 'failed' + } + }); + } + if (error !== 1) { + await saveBuildLog({ line: error, buildId, applicationId: application.id }); + } + if (error instanceof Error) { + await saveBuildLog({ + line: error.message, + buildId, + applicationId: application.id + }); + } + await fs.rm(workdir, { recursive: true, force: true }); + return; + } + try { + if (application.dockerRegistryImageName) { + const customTag = application.dockerRegistryImageName.split(':')[1] || buildId; + const imageName = application.dockerRegistryImageName.split(':')[0]; + await saveBuildLog({ + line: `Pushing ${imageName}:${customTag} to Docker Registry... It could take a while...`, + buildId, + applicationId: application.id + }); + await pushToRegistry(application, workdir, buildId, imageName, customTag); + await saveBuildLog({ line: 'Success', buildId, applicationId: application.id }); + } + } catch (error) { + if (error.stdout) { + await saveBuildLog({ line: error.stdout, buildId, applicationId }); + } + if (error.stderr) { + await saveBuildLog({ line: error.stderr, buildId, applicationId }); + } + } finally { + await fs.rm(workdir, { recursive: true, force: true }); + await prisma.build.update({ + where: { id: buildId }, + data: { status: 'success' } + }); + } + return; + } + + const originalApplicationId = application.id; + const { + id: applicationId, + name, + destinationDocker, + destinationDockerId, + gitSource, + configHash, + fqdn, + projectId, + secrets, + phpModules, + settings, + persistentStorage, + pythonWSGI, + pythonModule, + pythonVariable, + denoOptions, + exposePort, + baseImage, + baseBuildImage, + deploymentType, + gitCommitHash, + dockerRegistry + } = application; + + let { + branch, + repository, + buildPack, + port, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory, + dockerFileLocation, + dockerComposeFileLocation, + dockerComposeConfiguration, + denoMainFile + } = application; + + let imageId = applicationId; + let domain = getDomain(fqdn); + + let location = null; + + let tag = null; + let customTag = null; + let imageName = null; + + let imageFoundLocally = false; + let imageFoundRemotely = false; + + if (pullmergeRequestId) { + const previewApplications = await prisma.previewApplication.findMany({ + where: { applicationId: originalApplicationId, pullmergeRequestId } + }); + if (previewApplications.length > 0) { + previewApplicationId = previewApplications[0].id; + } + // Previews, we need to get the source branch and set subdomain + branch = sourceBranch; + domain = `${pullmergeRequestId}.${domain}`; + imageId = `${applicationId}-${pullmergeRequestId}`; + repository = sourceRepository || repository; + } + const { workdir, repodir } = await createDirectories({ repository, buildId }); + try { + if (queueBuild.status === 'running') { + await saveBuildLog({ + line: 'Building halted, restarting...', + buildId, + applicationId: application.id + }); + } + + const currentHash = crypto + .createHash('sha256') + .update( + JSON.stringify({ + pythonWSGI, + pythonModule, + pythonVariable, + deploymentType, + denoOptions, + baseImage, + baseBuildImage, + buildPack, + port, + exposePort, + installCommand, + buildCommand, + startCommand, + secrets, + branch, + repository, + fqdn + }) + ) + .digest('hex'); + const { debug } = settings; + if (!debug) { + await saveBuildLog({ + line: `Debug logging is disabled. Enable it above if necessary!`, + buildId, + applicationId + }); + } + const volumes = + persistentStorage?.map((storage) => { + if (storage.oldPath) { + return `${applicationId}${storage.path + .replace(/\//gi, '-') + .replace('-app', '')}:${storage.path}`; + } + return `${applicationId}${storage.path.replace(/\//gi, '-')}:${storage.path}`; + }) || []; + + try { + dockerComposeConfiguration = JSON.parse(dockerComposeConfiguration); + } catch (error) {} + let deployNeeded = true; + let destinationType; + + if (destinationDockerId) { + destinationType = 'docker'; + } + if (destinationType === 'docker') { + await prisma.build.update({ + where: { id: buildId }, + data: { status: 'running' } + }); + + const configuration = await setDefaultConfiguration(application); + + buildPack = configuration.buildPack; + port = configuration.port; + installCommand = configuration.installCommand; + startCommand = configuration.startCommand; + buildCommand = configuration.buildCommand; + publishDirectory = configuration.publishDirectory; + baseDirectory = configuration.baseDirectory || ''; + dockerFileLocation = configuration.dockerFileLocation; + dockerComposeFileLocation = configuration.dockerComposeFileLocation; + denoMainFile = configuration.denoMainFile; + const commit = await importers[gitSource.type]({ + applicationId, + debug, + workdir, + repodir, + githubAppId: gitSource.githubApp?.id, + gitlabAppId: gitSource.gitlabApp?.id, + customPort: gitSource.customPort, + gitCommitHash, + configuration, + repository, + branch, + buildId, + apiUrl: gitSource.apiUrl, + htmlUrl: gitSource.htmlUrl, + projectId, + deployKeyId: gitSource.gitlabApp?.deployKeyId || null, + privateSshKey: decrypt(gitSource.gitlabApp?.privateSshKey) || null, + forPublic: gitSource.forPublic + }); + if (!commit) { + throw new Error('No commit found?'); + } + tag = commit.slice(0, 7); + if (pullmergeRequestId) { + tag = `${commit.slice(0, 7)}-${pullmergeRequestId}`; + } + if (application.dockerRegistryImageName) { + imageName = application.dockerRegistryImageName.split(':')[0]; + customTag = application.dockerRegistryImageName.split(':')[1] || tag; + } else { + customTag = tag; + imageName = applicationId; + } + + if (pullmergeRequestId) { + customTag = `${customTag}-${pullmergeRequestId}`; + } + + try { + await prisma.build.update({ where: { id: buildId }, data: { commit } }); + } catch (err) {} + + if (!pullmergeRequestId) { + if (configHash !== currentHash) { + deployNeeded = true; + if (configHash) { + await saveBuildLog({ + line: 'Configuration changed', + buildId, + applicationId + }); + } + } else { + deployNeeded = false; + } + } else { + deployNeeded = true; + } + + try { + await executeCommand({ + dockerId: destinationDocker.id, + command: `docker image inspect ${applicationId}:${tag}` + }); + imageFoundLocally = true; + } catch (error) { + // + } + if (dockerRegistry) { + const { url, username, password } = dockerRegistry; + location = await saveDockerRegistryCredentials({ + url, + username, + password, + workdir + }); + } + + try { + await executeCommand({ + dockerId: destinationDocker.id, + command: `docker ${ + location ? `--config ${location}` : '' + } pull ${imageName}:${customTag}` + }); + imageFoundRemotely = true; + } catch (error) { + // + } + let imageFound = `${applicationId}:${tag}`; + if (imageFoundRemotely) { + imageFound = `${imageName}:${customTag}`; + } + await copyBaseConfigurationFiles( + buildPack, + workdir, + buildId, + applicationId, + baseImage + ); + const labels = makeLabelForStandaloneApplication({ + applicationId, + fqdn, + name, + type, + pullmergeRequestId, + buildPack, + repository, + branch, + projectId, + port: exposePort ? `${exposePort}:${port}` : port, + commit, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory + }); + if (forceRebuild) deployNeeded = true; + if ((!imageFoundLocally && !imageFoundRemotely) || deployNeeded) { + if (buildpacks[buildPack]) + await buildpacks[buildPack]({ + dockerId: destinationDocker.id, + network: destinationDocker.network, + buildId, + applicationId, + domain, + name, + type, + volumes, + labels, + pullmergeRequestId, + buildPack, + repository, + branch, + projectId, + publishDirectory, + debug, + commit, + tag, + workdir, + port: exposePort ? `${exposePort}:${port}` : port, + installCommand, + buildCommand, + startCommand, + baseDirectory, + secrets, + phpModules, + pythonWSGI, + pythonModule, + pythonVariable, + dockerFileLocation, + dockerComposeConfiguration, + dockerComposeFileLocation, + denoMainFile, + denoOptions, + baseImage, + baseBuildImage, + deploymentType, + forceRebuild + }); + else { + await saveBuildLog({ + line: `Build pack ${buildPack} not found`, + buildId, + applicationId + }); + throw new Error(`Build pack ${buildPack} not found.`); + } + } else { + if (imageFoundRemotely || deployNeeded) { + await saveBuildLog({ + line: `Container image ${imageFound} found in Docker Registry - reuising it`, + buildId, + applicationId + }); + } else { + if (imageFoundLocally || deployNeeded) { + await saveBuildLog({ + line: `Container image ${imageFound} found locally - reuising it`, + buildId, + applicationId + }); + } + } + } + + if (buildPack === 'compose') { + try { + const { stdout: containers } = await executeCommand({ + dockerId: destinationDockerId, + command: `docker ps -a --filter 'label=coolify.applicationId=${applicationId}' --format {{.ID}}` + }); + if (containers) { + const containerArray = containers.split('\n'); + if (containerArray.length > 0) { + for (const container of containerArray) { + await executeCommand({ + dockerId: destinationDockerId, + command: `docker stop -t 0 ${container}` + }); + await executeCommand({ + dockerId: destinationDockerId, + command: `docker rm --force ${container}` + }); + } + } + } + } catch (error) { + // + } + try { + await executeCommand({ + debug, + buildId, + applicationId, + dockerId: destinationDocker.id, + command: `docker compose --project-directory ${workdir} up -d` + }); + await saveBuildLog({ line: 'Deployed 🎉', buildId, applicationId }); + await prisma.build.update({ + where: { id: buildId }, + data: { status: 'success' } + }); + await prisma.application.update({ + where: { id: applicationId }, + data: { configHash: currentHash } + }); + } catch (error) { + await saveBuildLog({ line: error, buildId, applicationId }); + const foundBuild = await prisma.build.findUnique({ where: { id: buildId } }); + if (foundBuild) { + await prisma.build.update({ + where: { id: buildId }, + data: { + status: 'failed' + } + }); + } + throw new Error(error); + } + } else { + try { + const { stdout: containers } = await executeCommand({ + dockerId: destinationDockerId, + command: `docker ps -a --filter 'label=com.docker.compose.service=${ + pullmergeRequestId ? imageId : applicationId + }' --format {{.ID}}` + }); + if (containers) { + const containerArray = containers.split('\n'); + if (containerArray.length > 0) { + for (const container of containerArray) { + await executeCommand({ + dockerId: destinationDockerId, + command: `docker stop -t 0 ${container}` + }); + await executeCommand({ + dockerId: destinationDockerId, + command: `docker rm --force ${container}` + }); + } + } + } + } catch (error) { + // + } + let envs = []; + if (secrets.length > 0) { + envs = [ + ...envs, + ...generateSecrets(secrets, pullmergeRequestId, false, port) + ]; + } + if (dockerRegistry) { + const { url, username, password } = dockerRegistry; + await saveDockerRegistryCredentials({ url, username, password, workdir }); + } + try { + const composeVolumes = volumes.map((volume) => { + return { + [`${volume.split(':')[0]}`]: { + name: volume.split(':')[0] + } + }; + }); + const composeFile = { + version: '3.8', + services: { + [imageId]: { + image: imageFound, + container_name: imageId, + volumes, + environment: envs, + labels, + depends_on: [], + expose: [port], + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + ...defaultComposeConfiguration(destinationDocker.network) + } + }, + networks: { + [destinationDocker.network]: { + external: true + } + }, + volumes: Object.assign({}, ...composeVolumes) + }; + await fs.writeFile(`${workdir}/docker-compose.yml`, yaml.dump(composeFile)); + await executeCommand({ + debug, + dockerId: destinationDocker.id, + command: `docker compose --project-directory ${workdir} up -d` + }); + await saveBuildLog({ line: 'Deployed 🎉', buildId, applicationId }); + } catch (error) { + await saveBuildLog({ line: error, buildId, applicationId }); + const foundBuild = await prisma.build.findUnique({ where: { id: buildId } }); + if (foundBuild) { + await prisma.build.update({ + where: { id: buildId }, + data: { + status: 'failed' + } + }); + } + throw new Error(error); + } + + if (!pullmergeRequestId) + await prisma.application.update({ + where: { id: applicationId }, + data: { configHash: currentHash } + }); + } + } + } catch (error) { + const foundBuild = await prisma.build.findUnique({ where: { id: buildId } }); + if (foundBuild) { + await prisma.build.update({ + where: { id: buildId }, + data: { + status: 'failed' + } + }); + } + if (error !== 1) { + await saveBuildLog({ line: error, buildId, applicationId: application.id }); + } + if (error instanceof Error) { + await saveBuildLog({ + line: error.message, + buildId, + applicationId: application.id + }); + } + await fs.rm(workdir, { recursive: true, force: true }); + return; + } + try { + if (application.dockerRegistryImageName && (!imageFoundRemotely || forceRebuild)) { + await saveBuildLog({ + line: `Pushing ${imageName}:${customTag} to Docker Registry... It could take a while...`, + buildId, + applicationId: application.id + }); + await pushToRegistry(application, workdir, tag, imageName, customTag); + await saveBuildLog({ line: 'Success', buildId, applicationId: application.id }); + } + } catch (error) { + if (error.stdout) { + await saveBuildLog({ line: error.stdout, buildId, applicationId }); + } + if (error.stderr) { + await saveBuildLog({ line: error.stderr, buildId, applicationId }); + } + } finally { + await fs.rm(workdir, { recursive: true, force: true }); + await prisma.build.update({ where: { id: buildId }, data: { status: 'success' } }); + } + }); + } + await pAll.default(actions, { concurrency }); + } + } catch (error) { + console.log(error); + } + }); + while (true) { + await th(); + } + } else process.exit(0); +})(); diff --git a/apps/server/src/jobs/worker.ts b/apps/server/src/jobs/worker.ts new file mode 100644 index 000000000..981171c92 --- /dev/null +++ b/apps/server/src/jobs/worker.ts @@ -0,0 +1,9 @@ +import { parentPort } from 'node:worker_threads'; +import process from 'node:process'; + +console.log('Hello TypeScript!'); + +// signal to parent that the job is done +if (parentPort) parentPort.postMessage('done'); +// eslint-disable-next-line unicorn/no-process-exit +else process.exit(0); diff --git a/apps/server/src/lib/common.ts b/apps/server/src/lib/common.ts index ad502f54c..5b200a5b2 100644 --- a/apps/server/src/lib/common.ts +++ b/apps/server/src/lib/common.ts @@ -9,6 +9,7 @@ import type { Config } from 'unique-names-generator'; import { env } from '../env'; import { day } from './dayjs'; import { executeCommand } from './executeCommand'; +import { saveBuildLog } from './logging'; const customConfig: Config = { dictionaries: [adjectives, colors, animals], @@ -526,3 +527,11 @@ export const scanningTemplates = { buildPack: 'react' } }; + +export async function cleanupDB(buildId: string, applicationId: string) { + const data = await prisma.build.findUnique({ where: { id: buildId } }); + if (data?.status === 'queued' || data?.status === 'running') { + await prisma.build.update({ where: { id: buildId }, data: { status: 'canceled' } }); + } + await saveBuildLog({ line: 'Canceled.', buildId, applicationId }); +} diff --git a/apps/server/src/scheduler.ts b/apps/server/src/scheduler.ts new file mode 100644 index 000000000..e8df78d63 --- /dev/null +++ b/apps/server/src/scheduler.ts @@ -0,0 +1,26 @@ +import Bree from 'bree'; +import path from 'path'; +import Cabin from 'cabin'; +import TSBree from '@breejs/ts-worker'; + +export const isDev = process.env['NODE_ENV'] === 'development'; + +Bree.extend(TSBree); + +const options: any = { + defaultExtension: 'js', + logger: new Cabin(), + // logger: false, + // workerMessageHandler: async ({ name, message }) => { + // if (name === 'deployApplication' && message?.deploying) { + // if (scheduler.workers.has('autoUpdater') || scheduler.workers.has('cleanupStorage')) { + // scheduler.workers.get('deployApplication').postMessage('cancel') + // } + // } + // }, + // jobs: [{ name: 'deployApplication' }] + jobs: [{ name: 'worker' }] +}; +if (isDev) options.root = path.join(__dirname, '../jobs'); + +export const scheduler = new Bree(options); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9a8748fe3..e9dd64876 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -7,6 +7,8 @@ import * as path from 'node:path'; import serve from '@fastify/static'; import autoLoad from '@fastify/autoload'; // import { prisma } from './prisma'; +import Graceful from '@ladjs/graceful'; +import { scheduler } from './scheduler'; const isDev = process.env['NODE_ENV'] === 'development'; @@ -60,6 +62,9 @@ export function createServer(opts: ServerOptions) { try { await server.listen({ host: '0.0.0.0', port }); console.log('Coolify server is listening on port', port, 'at 0.0.0.0 🚀'); + const graceful = new Graceful({ brees: [scheduler] }); + graceful.listen(); + scheduler.run('worker'); } catch (err) { server.log.error(err); process.exit(1); diff --git a/apps/server/src/trpc/routers/applications/index.ts b/apps/server/src/trpc/routers/applications/index.ts index 490554d16..0b51690f5 100644 --- a/apps/server/src/trpc/routers/applications/index.ts +++ b/apps/server/src/trpc/routers/applications/index.ts @@ -20,6 +20,7 @@ import cuid from 'cuid'; import { checkDomainsIsValidInDNS, checkExposedPort, + cleanupDB, createDirectories, decrypt, encrypt, @@ -29,8 +30,220 @@ import { saveDockerRegistryCredentials, setDefaultConfiguration } from '../../../lib/common'; +import { day } from '../../../lib/dayjs'; +import csv from 'csvtojson'; export const applicationsRouter = router({ + resetQueue: privateProcedure.mutation(async ({ ctx }) => { + const teamId = ctx.user.teamId; + if (teamId === '0') { + await prisma.build.updateMany({ + where: { status: { in: ['queued', 'running'] } }, + data: { status: 'canceled' } + }); + // scheduler.workers.get("deployApplication").postMessage("cancel"); + } + }), + cancelBuild: privateProcedure + .input( + z.object({ + buildId: z.string(), + applicationId: z.string() + }) + ) + .mutation(async ({ input }) => { + const { buildId, applicationId } = input; + let count = 0; + await new Promise(async (resolve, reject) => { + const { destinationDockerId, status } = await prisma.build.findFirst({ + where: { id: buildId } + }); + const { id: dockerId } = await prisma.destinationDocker.findFirst({ + where: { id: destinationDockerId } + }); + const interval = setInterval(async () => { + try { + if (status === 'failed' || status === 'canceled') { + clearInterval(interval); + return resolve(); + } + if (count > 15) { + clearInterval(interval); + // if (scheduler.workers.has('deployApplication')) { + // scheduler.workers.get('deployApplication').postMessage('cancel'); + // } + await cleanupDB(buildId, applicationId); + return reject(new Error('Canceled.')); + } + const { stdout: buildContainers } = await executeCommand({ + dockerId, + command: `docker container ls --filter "label=coolify.buildId=${buildId}" --format '{{json .}}'` + }); + if (buildContainers) { + const containersArray = buildContainers.trim().split('\n'); + for (const container of containersArray) { + const containerObj = JSON.parse(container); + const id = containerObj.ID; + if (!containerObj.Names.startsWith(`${applicationId} `)) { + await removeContainer({ id, dockerId }); + clearInterval(interval); + // if (scheduler.workers.has('deployApplication')) { + // scheduler.workers.get('deployApplication').postMessage('cancel'); + // } + await cleanupDB(buildId, applicationId); + return resolve(); + } + } + } + count++; + } catch (error) {} + }, 100); + }); + }), + getBuildLogs: privateProcedure + .input( + z.object({ + id: z.string(), + buildId: z.string(), + sequence: z.number() + }) + ) + .query(async ({ input }) => { + let { id, buildId, sequence } = input; + let file = `/app/logs/${id}_buildlog_${buildId}.csv`; + if (isDev) { + file = `${process.cwd()}/../../logs/${id}_buildlog_${buildId}.csv`; + } + const data = await prisma.build.findFirst({ where: { id: buildId } }); + const createdAt = day(data.createdAt).utc(); + try { + await fs.stat(file); + } catch (error) { + let logs = await prisma.buildLog.findMany({ + where: { buildId, time: { gt: sequence } }, + orderBy: { time: 'asc' } + }); + const data = await prisma.build.findFirst({ where: { id: buildId } }); + const createdAt = day(data.createdAt).utc(); + return { + logs: logs.map((log) => { + log.time = Number(log.time); + return log; + }), + fromDb: true, + took: day().diff(createdAt) / 1000, + status: data?.status || 'queued' + }; + } + let fileLogs = (await fs.readFile(file)).toString(); + let decryptedLogs = await csv({ noheader: true }).fromString(fileLogs); + let logs = decryptedLogs + .map((log) => { + const parsed = { + time: log['field1'], + line: decrypt(log['field2'] + '","' + log['field3']) + }; + return parsed; + }) + .filter((log) => log.time > sequence); + return { + logs, + fromDb: false, + took: day().diff(createdAt) / 1000, + status: data?.status || 'queued' + }; + }), + getBuilds: privateProcedure + .input( + z.object({ + id: z.string(), + buildId: z.string().optional(), + skip: z.number() + }) + ) + .query(async ({ input }) => { + let { id, buildId, skip } = input; + let builds = []; + const buildCount = await prisma.build.count({ where: { applicationId: id } }); + if (buildId) { + builds = await prisma.build.findMany({ where: { applicationId: id, id: buildId } }); + } else { + builds = await prisma.build.findMany({ + where: { applicationId: id }, + orderBy: { createdAt: 'desc' }, + take: 5 + skip + }); + } + builds = builds.map((build) => { + if (build.status === 'running') { + build.elapsed = (day().utc().diff(day(build.createdAt)) / 1000).toFixed(0); + } + return build; + }); + return { + builds, + buildCount + }; + }), + loadLogs: privateProcedure + .input( + z.object({ + id: z.string(), + containerId: z.string(), + since: z.number() + }) + ) + .query(async ({ input }) => { + let { id, containerId, since } = input; + if (since !== 0) { + since = day(since).unix(); + } + const { + destinationDockerId, + destinationDocker: { id: dockerId } + } = await prisma.application.findUnique({ + where: { id }, + include: { destinationDocker: true } + }); + if (destinationDockerId) { + try { + const { default: ansi } = await import('strip-ansi'); + const { stdout, stderr } = await executeCommand({ + dockerId, + command: `docker logs --since ${since} --tail 5000 --timestamps ${containerId}` + }); + const stripLogsStdout = stdout + .toString() + .split('\n') + .map((l) => ansi(l)) + .filter((a) => a); + const stripLogsStderr = stderr + .toString() + .split('\n') + .map((l) => ansi(l)) + .filter((a) => a); + const logs = stripLogsStderr.concat(stripLogsStdout); + const sortedLogs = logs.sort((a, b) => + day(a.split(' ')[0]).isAfter(day(b.split(' ')[0])) ? 1 : -1 + ); + return { logs: sortedLogs }; + // } + } catch (error) { + const { statusCode, stderr } = error; + if (stderr.startsWith('Error: No such container')) { + return { logs: [], noContainer: true }; + } + if (statusCode === 404) { + return { + logs: [] + }; + } + } + } + return { + message: 'No logs found.' + }; + }), getStorages: privateProcedure .input( z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2c9431e..c6112f7f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,7 @@ importers: autoprefixer: 10.4.13 cuid: 2.1.8 daisyui: 2.41.0 + dayjs: 1.11.6 eslint: 8.28.0 eslint-config-prettier: 8.5.0 eslint-plugin-svelte3: 4.0.0 @@ -192,6 +193,7 @@ importers: '@trpc/server': 10.1.0 cuid: 2.1.8 daisyui: 2.41.0_2lwn2upnx27dqeg6hqdu7sq75m + dayjs: 1.11.6 flowbite-svelte: 0.28.0 js-cookie: 3.0.1 js-yaml: 4.1.0 @@ -236,12 +238,14 @@ importers: apps/server: specifiers: + '@breejs/ts-worker': 2.0.0 '@fastify/autoload': 5.6.0 '@fastify/cors': 8.2.0 '@fastify/env': 4.1.0 '@fastify/jwt': 6.5.0 '@fastify/static': 6.6.0 '@fastify/websocket': 7.1.1 + '@ladjs/graceful': 3.0.2 '@prisma/client': 4.6.1 '@trpc/client': 10.1.0 '@trpc/server': 10.1.0 @@ -253,7 +257,11 @@ importers: '@types/shell-quote': ^1.7.1 '@types/ws': 8.5.3 abort-controller: 3.0.0 + axe: 11.0.0 bcryptjs: 2.4.3 + bree: 9.1.2 + cabin: 11.0.1 + csvtojson: 2.0.10 cuid: 2.1.8 dayjs: 1.11.6 dotenv: ^16.0.3 @@ -267,11 +275,14 @@ importers: jsonwebtoken: 8.5.1 node-fetch: 3.3.0 npm-run-all: 4.1.5 + p-all: 4.0.0 + p-throttle: 5.0.0 prisma: 4.6.1 rimraf: 3.0.2 shell-quote: ^1.7.4 ssh-config: 4.1.6 start-server-and-test: 1.14.0 + strip-ansi: 7.0.1 superjson: 1.11.0 tslib: 2.4.1 tsx: 3.12.1 @@ -281,17 +292,23 @@ importers: ws: 8.11.0 zod: 3.19.1 dependencies: + '@breejs/ts-worker': 2.0.0_7ja7ufy2vbczkqoi6dab6h7sdi '@fastify/autoload': 5.6.0 '@fastify/cors': 8.2.0 '@fastify/env': 4.1.0 '@fastify/jwt': 6.5.0 '@fastify/static': 6.6.0 '@fastify/websocket': 7.1.1 + '@ladjs/graceful': 3.0.2 '@prisma/client': 4.6.1_prisma@4.6.1 '@trpc/client': 10.1.0_@trpc+server@10.1.0 '@trpc/server': 10.1.0 abort-controller: 3.0.0 + axe: 11.0.0 bcryptjs: 2.4.3 + bree: 9.1.2 + cabin: 11.0.1_axe@11.0.0 + csvtojson: 2.0.10 cuid: 2.1.8 dayjs: 1.11.6 dotenv: 16.0.3 @@ -304,9 +321,12 @@ importers: js-yaml: 4.1.0 jsonwebtoken: 8.5.1 node-fetch: 3.3.0 + p-all: 4.0.0 + p-throttle: 5.0.0 prisma: 4.6.1 shell-quote: 1.7.4 ssh-config: 4.1.6 + strip-ansi: 7.0.1 superjson: 1.11.0 tslib: 2.4.1 unique-names-generator: 4.7.1 @@ -1424,6 +1444,22 @@ packages: engines: {node: '>= 10'} dev: false + /@breejs/ts-worker/2.0.0_7ja7ufy2vbczkqoi6dab6h7sdi: + resolution: {integrity: sha512-6anHRcmgYlF7mrm/YVRn6rx2cegLuiY3VBxkkimOTWC/dVQeH336imVSuIKEGKTwiuNTPr2hswVdDSneNuXg3A==} + engines: {node: '>= 12.11'} + peerDependencies: + bree: '>=9.0.0' + dependencies: + bree: 9.1.2 + ts-node: 10.8.2_wup25etrarvlqkprac7h35hj7u + tsconfig-paths: 4.1.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - typescript + dev: false + /@breejs/ts-worker/2.0.0_rfg2b5n3b6pycmpydtv43bmupy: resolution: {integrity: sha512-6anHRcmgYlF7mrm/YVRn6rx2cegLuiY3VBxkkimOTWC/dVQeH336imVSuIKEGKTwiuNTPr2hswVdDSneNuXg3A==} engines: {node: '>= 12.11'}